Skip to content

Commit cffcc85

Browse files
Backend | CB-222 NestJS App structure for DDD on CQRS write-side
- Project structure for write-side of CQRS - Base classes for Aggregates and Domain Events implementations. - EventStorage library with in-memory and postgres implementations. - App time source-of-truth by TimeProvider. - Sample of functionality with inviting applicant to CodersCrew, based on EventStorming.
1 parent b14a901 commit cffcc85

File tree

80 files changed

+13834
-106
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

80 files changed

+13834
-106
lines changed

backend/.eslintrc.js

100644100755
+3
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,8 @@ module.exports = {
2020
'@typescript-eslint/interface-name-prefix': 'off',
2121
'@typescript-eslint/explicit-function-return-type': 'off',
2222
'@typescript-eslint/no-explicit-any': 'off',
23+
"@typescript-eslint/no-namespace": "off",
24+
"@typescript-eslint/no-empty-interface": "off",
25+
"@typescript-eslint/no-use-before-define": "off"
2326
},
2427
};

backend/.prettierrc

100644100755
+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"singleQuote": true,
33
"trailingComma": "all"
4-
}
4+
}

backend/Dockerfile

100644100755
File mode changed.

backend/docker-compose.yml

100644100755
+48-25
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,48 @@
1-
version: "3.7"
2-
3-
services:
4-
coders-board:
5-
build:
6-
args:
7-
NODE_ENV: development
8-
IMAGE_BASE_DEV: node:alpine
9-
IMAGE_BASE_PROD: node:alpine
10-
context: .
11-
target: development
12-
13-
volumes:
14-
- .:/app
15-
- /app/node_modules
16-
ports:
17-
- 3000:3000
18-
- 9229:9229
19-
command:
20-
- start:debug
21-
networks:
22-
- coders-board-network
23-
networks:
24-
coders-board-network:
25-
name: coders-board-network
1+
version: '3.7'
2+
3+
services:
4+
coders-board:
5+
build:
6+
args:
7+
NODE_ENV: development
8+
IMAGE_BASE_DEV: node:alpine
9+
IMAGE_BASE_PROD: node:alpine
10+
context: .
11+
target: development
12+
13+
container_name: coders-board-nestjs
14+
volumes:
15+
- .:/app
16+
- /app/node_modules
17+
ports:
18+
- 3000:3000
19+
- 9229:9229
20+
environment:
21+
DATABASE_MODE: in-memory
22+
DATABASE_HOST: postgres
23+
DATABASE_PORT: 5432
24+
DATABASE_USERNAME: postgres
25+
DATABASE_PASSWORD: postgres
26+
command:
27+
- start:debug
28+
networks:
29+
- coders-board-network
30+
31+
postgres:
32+
image: postgres:12
33+
container_name: coders-board-postgres
34+
restart: always
35+
ports:
36+
- 5002:5432
37+
environment:
38+
POSTGRES_PASSWORD: postgres
39+
POSTGRES_USER: postgres
40+
POSTGRES_DB: coders-board
41+
networks:
42+
- coders-board-network
43+
#volumes:
44+
# - ../pgdata:/var/lib/postgresql/data #keep data outside container
45+
46+
networks:
47+
coders-board-network:
48+
name: coders-board-network
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export class EventStreamVersion {
2+
constructor(readonly raw: number) {}
3+
4+
static exactly(raw: number) {
5+
return new EventStreamVersion(raw);
6+
}
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export interface StorageEventEntry {
2+
readonly eventId: string;
3+
readonly eventType: string;
4+
readonly occurredAt: Date;
5+
readonly aggregateId: string;
6+
readonly aggregateType: string;
7+
readonly payload: any;
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ModuleMetadata, Type } from '@nestjs/common/interfaces';
2+
import { EventSourcingModuleConfigFactory } from '@coders-board-library/event-sourcing/event-sourcing.module-config-factory';
3+
import { EventSourcingModuleConfig } from '@coders-board-library/event-sourcing/event-sourcing.module-config';
4+
5+
export interface EventSourcingModuleAsyncConfig
6+
extends Pick<ModuleMetadata, 'imports'> {
7+
inject?: any[];
8+
useExisting?: Type<EventSourcingModuleConfigFactory>;
9+
useClass?: Type<EventSourcingModuleConfigFactory>;
10+
useFactory?: (
11+
...args: any[]
12+
) => Promise<EventSourcingModuleConfig> | EventSourcingModuleConfig;
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { EventSourcingModuleConfig } from '@coders-board-library/event-sourcing/event-sourcing.module-config';
2+
3+
export interface EventSourcingModuleConfigFactory {
4+
createModuleConfig():
5+
| Promise<EventSourcingModuleConfig>
6+
| EventSourcingModuleConfig;
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { TypeOrmModule } from '@nestjs/typeorm';
2+
import { Time } from './time.type';
3+
4+
export type EventSourcingModuleConfig =
5+
| {
6+
time: Time;
7+
eventStorage: 'in-memory';
8+
}
9+
| {
10+
time: Time;
11+
eventStorage: 'typeorm';
12+
typeOrmModule: TypeOrmModule;
13+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { DynamicModule, Module, Provider } from '@nestjs/common';
2+
import { EVENT_STORAGE, EventStorage } from './event-storage/event-storage';
3+
import { TypeOrmEventStorage } from './event-storage/typeorm/event-storage.typeorm';
4+
import { InMemoryEventStorage } from './event-storage/in-memory/event-storage.in-memory';
5+
import { DomainEventEntity } from './event-storage/typeorm/event.typeorm-entity';
6+
import { EventSourcingModuleConfig } from './event-sourcing.module-config';
7+
import { Repository } from 'typeorm';
8+
import { EventSourcingModuleAsyncConfig } from '@coders-board-library/event-sourcing/event-sourcing.module-async-config';
9+
import { Time } from '@coders-board-library/event-sourcing/time.type';
10+
import { EventSourcingModuleConfigFactory } from '@coders-board-library/event-sourcing/event-sourcing.module-config-factory';
11+
12+
const EVENT_SOURCING_CONFIG = Symbol();
13+
14+
@Module({})
15+
export class EventSourcingModule {
16+
static register(config: EventSourcingModuleConfig): DynamicModule {
17+
const configProvider: Provider = {
18+
provide: EVENT_SOURCING_CONFIG,
19+
useValue: config,
20+
};
21+
if (config.eventStorage === 'typeorm') {
22+
const optionalImports = [];
23+
optionalImports.push(config.typeOrmModule);
24+
return {
25+
module: EventSourcingModule,
26+
imports: [...optionalImports],
27+
providers: [
28+
{
29+
provide: EVENT_STORAGE,
30+
useFactory: (typeOrmRepository: Repository<DomainEventEntity>) =>
31+
new TypeOrmEventStorage(config.time, typeOrmRepository),
32+
},
33+
],
34+
exports: [EVENT_STORAGE],
35+
};
36+
}
37+
38+
return {
39+
module: EventSourcingModule,
40+
providers: [
41+
configProvider,
42+
{
43+
provide: EVENT_STORAGE,
44+
useFactory: (config: EventSourcingModuleConfig) =>
45+
new InMemoryEventStorage(config.time),
46+
inject: [EVENT_SOURCING_CONFIG],
47+
},
48+
],
49+
exports: [EVENT_STORAGE],
50+
};
51+
}
52+
53+
static registerAsync(config: EventSourcingModuleAsyncConfig): DynamicModule {
54+
return {
55+
module: EventSourcingModule,
56+
imports: config.imports || [],
57+
providers: [
58+
this.createAsyncProviders(config),
59+
{
60+
provide: EVENT_STORAGE,
61+
useFactory: (config: EventSourcingModuleConfig) =>
62+
new InMemoryEventStorage(config.time),
63+
inject: [EVENT_SOURCING_CONFIG],
64+
},
65+
],
66+
exports: [EVENT_STORAGE],
67+
};
68+
}
69+
70+
private static createAsyncProviders(
71+
config: EventSourcingModuleAsyncConfig,
72+
): Provider {
73+
if (config) {
74+
if (config.useFactory) {
75+
return {
76+
provide: EVENT_SOURCING_CONFIG,
77+
useFactory: config.useFactory,
78+
inject: config.inject || [],
79+
};
80+
} else {
81+
return {
82+
provide: EVENT_SOURCING_CONFIG,
83+
useFactory: async (
84+
optionsFactory: EventSourcingModuleConfigFactory,
85+
) => await optionsFactory.createModuleConfig(),
86+
inject: [config.useExisting || config.useClass],
87+
};
88+
}
89+
} else {
90+
return {
91+
provide: EVENT_SOURCING_CONFIG,
92+
useValue: {},
93+
};
94+
}
95+
}
96+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { EventStreamVersion } from '../api/event-stream-version.valueobject';
2+
import { StorageEventEntry } from '../api/storage-event-entry';
3+
4+
export const EVENT_STORAGE = 'EventStorage';
5+
6+
export interface EventStorage {
7+
store(
8+
event: StorageEventEntry,
9+
expectedVersion?: EventStreamVersion,
10+
): Promise<void>;
11+
12+
//TODO: Consider interface change to return stored events and errors
13+
// or leave only method for store one event or do storeAll in one transaction
14+
storeAll(
15+
events: StorageEventEntry[],
16+
expectedVersion?: EventStreamVersion,
17+
): Promise<void>;
18+
19+
readEvents(aggregateId: string, toDate?: Date): Promise<StorageEventEntry[]>;
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { InMemoryEventStorage } from '@coders-board-library/event-sourcing/event-storage/in-memory/event-storage.in-memory';
2+
import { EventStorage } from '@coders-board-library/event-sourcing/event-storage/event-storage';
3+
import * as moment from 'moment';
4+
import { EventStreamVersion } from '@coders-board-library/event-sourcing/api/event-stream-version.valueobject';
5+
6+
const time = {
7+
15_00: moment.utc(new Date(2020, 4, 7, 15, 0)).toDate(),
8+
15_20: moment.utc(new Date(2020, 4, 7, 15, 20)).toDate(),
9+
15_30: moment.utc(new Date(2020, 4, 7, 15, 30)).toDate(),
10+
15_40: moment.utc(new Date(2020, 4, 7, 15, 40)).toDate(),
11+
};
12+
13+
const events = {
14+
aggregate1: {
15+
id: 'aggregateId1',
16+
event1: {
17+
eventId: 'eventId1.1',
18+
eventType: 'EVENT_TYPE_1',
19+
aggregateId: 'aggregateId1',
20+
aggregateType: 'aggregateType1',
21+
occurredAt: time['1530'],
22+
payload: { value: 'value' },
23+
},
24+
event2: {
25+
eventId: 'eventId1.2',
26+
eventType: 'EVENT_TYPE_2',
27+
aggregateId: 'aggregateId1',
28+
aggregateType: 'aggregateType1',
29+
occurredAt: time['1540'],
30+
payload: {},
31+
},
32+
},
33+
aggregate2: {
34+
id: 'aggregateId2',
35+
event1: {
36+
eventId: 'eventId2.1',
37+
eventType: 'EVENT_TYPE_1',
38+
aggregateId: 'aggregateId2',
39+
aggregateType: 'aggregateType2',
40+
occurredAt: time['1530'],
41+
payload: { value: 'value' },
42+
},
43+
event2: {
44+
eventId: 'eventId2.2',
45+
eventType: 'EVENT_TYPE_2',
46+
aggregateId: 'aggregateId2',
47+
aggregateType: 'aggregateType2',
48+
occurredAt: time['1540'],
49+
payload: {},
50+
},
51+
},
52+
};
53+
54+
describe('Feature: In memory event storage', () => {
55+
let currentDate: Date;
56+
let eventStorage: EventStorage;
57+
58+
beforeEach(() => {
59+
eventStorage = new InMemoryEventStorage(() => currentDate);
60+
});
61+
62+
describe('Given: Events to store', () => {
63+
describe('When: store the events', () => {
64+
beforeEach(() => {
65+
eventStorage.store(events.aggregate1.event1);
66+
eventStorage.store(events.aggregate2.event1);
67+
eventStorage.store(events.aggregate1.event2);
68+
eventStorage.store(events.aggregate2.event2);
69+
});
70+
71+
it('Then: The event should be queryable by all event', async () => {
72+
currentDate = time['1540'];
73+
const stored = await eventStorage.readEvents(events.aggregate1.id);
74+
expect(stored).toContain(events.aggregate1.event1);
75+
expect(stored).toContain(events.aggregate1.event2);
76+
});
77+
78+
it('Then: The event should be queryable by time', async () => {
79+
expect(
80+
await eventStorage.readEvents(events.aggregate1.id, time['1520']),
81+
).toStrictEqual([]);
82+
expect(
83+
await eventStorage.readEvents(events.aggregate1.id, time['1530']),
84+
).toContain(events.aggregate1.event1);
85+
86+
expect(
87+
await eventStorage.readEvents(events.aggregate1.id, time['1540']),
88+
).toContain(events.aggregate1.event1);
89+
expect(
90+
await eventStorage.readEvents(events.aggregate1.id, time['1540']),
91+
).toContain(events.aggregate1.event2);
92+
});
93+
94+
it('Then: The event cannot be stored twice', async () => {
95+
await expect(
96+
eventStorage.store(events.aggregate1.event1),
97+
).rejects.toMatch(
98+
`Event stream already contains this event with id ${events.aggregate1.event1.eventId}!`,
99+
);
100+
});
101+
102+
it('Then: The event cannot be stored if aggregate was modified', async () => {
103+
const anotherEvent2 = {
104+
eventId: 'eventId1.2_2',
105+
eventType: 'EVENT_TYPE_2',
106+
aggregateId: 'aggregateId1',
107+
aggregateType: 'aggregateType1',
108+
occurredAt: time['1540'],
109+
payload: {},
110+
};
111+
await expect(
112+
eventStorage.store(anotherEvent2, EventStreamVersion.exactly(1)),
113+
).rejects.toMatch(
114+
`Event stream for aggregate was modified! Expected version: 1, but actual is: 2`,
115+
);
116+
});
117+
});
118+
});
119+
});

0 commit comments

Comments
 (0)