Skip to content

Commit bae9fe5

Browse files
committed
feat(aggregate-root): added aggregate members logic and allowed entities to raise domain events
1 parent e55adaa commit bae9fe5

File tree

8 files changed

+201
-218
lines changed

8 files changed

+201
-218
lines changed

lib/core/src/aggregates/__tests__/aggregate-root.test.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ describe('AggregateRoot', () => {
7070
card.handle(new TransactCommand(cardId, 50));
7171

7272
expect(card.events()).toHaveLength(1);
73-
expect(card.transactions[0].events()).toHaveLength(1);
73+
expect(card.transactions[0].events()).toHaveLength(0);
7474

7575
// Reimburse transaction #1:
7676

@@ -79,15 +79,15 @@ describe('AggregateRoot', () => {
7979
);
8080

8181
expect(card.events()).toHaveLength(2);
82-
expect(card.transactions[0].events()).toHaveLength(2);
82+
expect(card.transactions[0].events()).toHaveLength(1);
8383

8484
// Add transaction #2:
8585

8686
card.handle(new TransactCommand(cardId, 50));
8787

8888
expect(card.events()).toHaveLength(3);
89-
expect(card.transactions[0].events()).toHaveLength(2);
90-
expect(card.transactions[1].events()).toHaveLength(1);
89+
expect(card.transactions[0].events()).toHaveLength(1);
90+
expect(card.transactions[1].events()).toHaveLength(0);
9191

9292
// Reimburse transaction #2:
9393

@@ -96,8 +96,8 @@ describe('AggregateRoot', () => {
9696
);
9797

9898
expect(card.events()).toHaveLength(4);
99-
expect(card.transactions[0].events()).toHaveLength(2);
100-
expect(card.transactions[1].events()).toHaveLength(2);
99+
expect(card.transactions[0].events()).toHaveLength(1);
100+
expect(card.transactions[1].events()).toHaveLength(1);
101101
});
102102

103103
test('child entity clearEvents works as expected', () => {
@@ -110,7 +110,7 @@ describe('AggregateRoot', () => {
110110
card.handle(new TransactCommand(cardId, 50));
111111

112112
expect(card.events()).toHaveLength(1);
113-
expect(card.transactions[0].events()).toHaveLength(1);
113+
expect(card.transactions[0].events()).toHaveLength(0);
114114

115115
// Reimburse transaction #1:
116116

@@ -119,33 +119,33 @@ describe('AggregateRoot', () => {
119119
);
120120

121121
expect(card.events()).toHaveLength(2);
122-
expect(card.transactions[0].events()).toHaveLength(2);
122+
expect(card.transactions[0].events()).toHaveLength(1);
123123

124124
// Add transaction #2:
125125

126126
card.handle(new TransactCommand(cardId, 50));
127127

128128
expect(card.events()).toHaveLength(3);
129-
expect(card.transactions[0].events()).toHaveLength(2);
130-
expect(card.transactions[1].events()).toHaveLength(1);
129+
expect(card.transactions[0].events()).toHaveLength(1);
130+
expect(card.transactions[1].events()).toHaveLength(0);
131131

132132
// Reimburse transaction #2:
133133

134134
card.handle(
135135
new ReimburseCardCommand(card.id, card.transactions[1].transactionId)
136136
);
137-
138137
expect(card.events()).toHaveLength(4);
139-
expect(card.transactions[0].events()).toHaveLength(2);
140-
expect(card.transactions[1].events()).toHaveLength(2);
138+
expect(card.transactions[0].events()).toHaveLength(1);
139+
expect(card.transactions[1].events()).toHaveLength(1);
141140

142141
// Clearing events:
143142

144-
card.transactions[0].clearEvents();
143+
const firstTransactionEvents = card.transactions[0].clearEvents();
144+
expect(firstTransactionEvents).toHaveLength(1);
145145

146-
expect(card.events()).toHaveLength(2);
146+
expect(card.events()).toHaveLength(3);
147147
expect(card.transactions[0].events()).toHaveLength(0);
148-
expect(card.transactions[1].events()).toHaveLength(2);
148+
expect(card.transactions[1].events()).toHaveLength(1);
149149
});
150150
});
151151
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { InvalidOperationException } from '../exceptions';
2+
import {
3+
AGGREGATE_MEMBER_METADATA,
4+
AGGREGATE_MEMBER_WATERMARK,
5+
Decorator
6+
} from '../helpers';
7+
import { ObjectLiteral } from '../types';
8+
9+
interface AggregateMemberMetadata {
10+
propertyKeys: Set<PropertyKey>;
11+
}
12+
13+
const watermarkSymbol = AGGREGATE_MEMBER_WATERMARK;
14+
const metadataSymbol = AGGREGATE_MEMBER_METADATA;
15+
16+
/**
17+
* Decorator that marks a property as the aggregate member.
18+
*/
19+
export function AggregateMember(): PropertyDecorator {
20+
/**
21+
* This function only runs once at runtime to set up the targetClass property descriptor.
22+
* All instances share the same `targetClass` variable.
23+
*/
24+
return function (targetClass: ObjectLiteral, propertyKey: PropertyKey): void {
25+
const { propertyKeys } = AggregateMember.getMetadata(targetClass) ?? {};
26+
if (propertyKeys) {
27+
if (propertyKeys.has(propertyKey)) {
28+
const name = targetClass.name || 'Object';
29+
throw new InvalidOperationException(
30+
`${name} already has an aggregate member with property key ${propertyKey.toString()}.`
31+
);
32+
}
33+
propertyKeys.add(propertyKey);
34+
} else {
35+
const metadata: AggregateMemberMetadata = {
36+
propertyKeys: new Set([propertyKey])
37+
};
38+
39+
Decorator.setWatermark(watermarkSymbol, targetClass);
40+
Decorator.setMetadata(metadataSymbol, metadata, targetClass);
41+
42+
Decorator.setWatermark(watermarkSymbol, targetClass.constructor);
43+
Decorator.setMetadata(metadataSymbol, metadata, targetClass.constructor);
44+
}
45+
};
46+
}
47+
48+
/**
49+
* Returns the aggregate members of the object.
50+
*/
51+
AggregateMember.getMembers = function <AggregateMember = unknown>(
52+
anObject: ObjectLiteral
53+
): AggregateMember[] {
54+
if (!AggregateMember.hasMembers(anObject)) {
55+
const name = anObject.name || anObject.constructor?.name || 'Object';
56+
throw new InvalidOperationException(
57+
`${name} does not have an aggregate members.`
58+
);
59+
}
60+
61+
const metadata = AggregateMember.getMetadata(anObject);
62+
63+
const members: AggregateMember[] = [];
64+
65+
for (const propertyKey of metadata!.propertyKeys) {
66+
const member = anObject[propertyKey];
67+
if (member) members.push(member);
68+
}
69+
70+
return members;
71+
};
72+
73+
/**
74+
* Returns the aggregate member metadata of the object.
75+
*/
76+
AggregateMember.getMetadata = function (targetClass: ObjectLiteral) {
77+
return Decorator.getMetadata<AggregateMemberMetadata | undefined>(
78+
metadataSymbol,
79+
targetClass
80+
);
81+
};
82+
83+
/**
84+
* Returns true if the object has an aggregate member.
85+
*/
86+
AggregateMember.hasMembers = function (anObject: ObjectLiteral): boolean {
87+
return Decorator.hasMetadata(watermarkSymbol, anObject);
88+
};

lib/core/src/domain-events/__tests__/domain-event.test.ts

Lines changed: 2 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -50,75 +50,10 @@ describe('DomainEvent', () => {
5050
});
5151
});
5252

53-
describe('options', () => {
54-
test('default options', () => {
53+
describe('when there is no Aggregate or Entity ID', () => {
54+
test('should throw', () => {
5555
expect(() => {
5656
@DomainEvent(faker.string.uuid(), faker.system.semver())
57-
class TestEvent {
58-
@AggregateId()
59-
public id: string;
60-
61-
constructor(id: string) {
62-
this.id = id;
63-
}
64-
}
65-
66-
return new TestEvent(faker.string.uuid());
67-
}).not.toThrow();
68-
69-
expect(() => {
70-
@DomainEvent(faker.string.uuid(), faker.system.semver())
71-
class TestEvent {
72-
@AggregateId()
73-
public id: string;
74-
75-
@EntityId()
76-
public anotherId: string;
77-
78-
constructor(id: string, anotherId: string) {
79-
this.id = id;
80-
this.anotherId = anotherId;
81-
}
82-
}
83-
84-
return new TestEvent(faker.string.uuid(), faker.string.uuid());
85-
}).not.toThrow();
86-
87-
expect(() => {
88-
@DomainEvent(faker.string.uuid(), faker.system.semver())
89-
class TestEvent {
90-
public id: string;
91-
92-
constructor(id: string) {
93-
this.id = id;
94-
}
95-
}
96-
97-
return new TestEvent(faker.string.uuid());
98-
}).toThrow();
99-
});
100-
101-
test('requireAggregateId works as expected', () => {
102-
expect(() => {
103-
@DomainEvent(faker.string.uuid(), faker.system.semver(), {
104-
requireAggregateId: true
105-
})
106-
class TestEvent {
107-
@AggregateId()
108-
public id: string;
109-
110-
constructor(id: string) {
111-
this.id = id;
112-
}
113-
}
114-
115-
return new TestEvent(faker.string.uuid());
116-
}).not.toThrow();
117-
118-
expect(() => {
119-
@DomainEvent(faker.string.uuid(), faker.system.semver(), {
120-
requireAggregateId: true
121-
})
12257
class TestEvent {
12358
public id: string;
12459

@@ -129,72 +64,6 @@ describe('DomainEvent', () => {
12964

13065
return new TestEvent(faker.string.uuid());
13166
}).toThrow();
132-
133-
expect(() => {
134-
@DomainEvent(faker.string.uuid(), faker.system.semver(), {
135-
requireAggregateId: false
136-
})
137-
class TestEvent {
138-
public id: string;
139-
140-
constructor(id: string) {
141-
this.id = id;
142-
}
143-
}
144-
145-
return new TestEvent(faker.string.uuid());
146-
}).not.toThrow();
147-
});
148-
149-
test('requireEntityId works as expected', () => {
150-
expect(() => {
151-
@DomainEvent(faker.string.uuid(), faker.system.semver(), {
152-
requireAggregateId: false,
153-
requireEntityId: true
154-
})
155-
class TestEvent {
156-
@EntityId()
157-
public id: string;
158-
159-
constructor(id: string) {
160-
this.id = id;
161-
}
162-
}
163-
164-
return new TestEvent(faker.string.uuid());
165-
}).not.toThrow();
166-
167-
expect(() => {
168-
@DomainEvent(faker.string.uuid(), faker.system.semver(), {
169-
requireAggregateId: false,
170-
requireEntityId: true
171-
})
172-
class TestEvent {
173-
public id: string;
174-
175-
constructor(id: string) {
176-
this.id = id;
177-
}
178-
}
179-
180-
return new TestEvent(faker.string.uuid());
181-
}).toThrow();
182-
183-
expect(() => {
184-
@DomainEvent(faker.string.uuid(), faker.system.semver(), {
185-
requireAggregateId: false,
186-
requireEntityId: false
187-
})
188-
class TestEvent {
189-
public id: string;
190-
191-
constructor(id: string) {
192-
this.id = id;
193-
}
194-
}
195-
196-
return new TestEvent(faker.string.uuid());
197-
}).not.toThrow();
19867
});
19968
});
20069
});

lib/core/src/domain-events/domain-event.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import { AggregateId } from '../aggregates';
22
import { EntityId } from '../entities';
3-
import {
4-
IllegalStateException,
5-
InvalidOperationException
6-
} from '../exceptions';
3+
import { InvalidOperationException } from '../exceptions';
74
import {
85
Decorator,
96
DOMAIN_EVENT_METADATA,
@@ -22,14 +19,8 @@ interface DomainEventMetadata {
2219
*/
2320
export function DomainEvent(
2421
type: DomainEventMetadata['type'],
25-
version: DomainEventMetadata['version'],
26-
options: Partial<{
27-
requireAggregateId: boolean;
28-
requireEntityId: boolean;
29-
}> = {}
22+
version: DomainEventMetadata['version']
3023
): ClassDecorator {
31-
const { requireAggregateId = true, requireEntityId = false } = options;
32-
3324
/**
3425
* This function only runs once at runtime.
3526
* All respective instances share the same `Class`.
@@ -56,11 +47,10 @@ export function DomainEvent(
5647
Decorator.setWatermark(DOMAIN_EVENT_WATERMARK, this);
5748
Decorator.setMetadata(DOMAIN_EVENT_METADATA, metadata, this);
5849

59-
if (requireAggregateId && !AggregateId.hasId(this))
60-
throw new IllegalStateException('AggregateId is required.');
61-
62-
if (requireEntityId && !EntityId.hasId(this))
63-
throw new IllegalStateException('EntityId is required.');
50+
if (!AggregateId.hasId(this) && !EntityId.hasId(this))
51+
throw new InvalidOperationException(
52+
'Domain Events must have an Aggregate ID or Entity ID.'
53+
);
6454
}
6555
};
6656
} as ClassDecorator;

0 commit comments

Comments
 (0)