first commit
This commit is contained in:
24
libs/api/shared/domain/README.md
Normal file
24
libs/api/shared/domain/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# api-shared-domain
|
||||
|
||||
Piezas de dominio comunes a todos los contextos de la API.
|
||||
|
||||
- **Import**: `@task-stream/api/shared/domain`
|
||||
- **Capa**: `domain` · **Tags**: `scope:api-shared`, `type:domain`
|
||||
|
||||
## Contenido
|
||||
|
||||
- `VersionedAggregateRoot`: extiende el `AggregateRoot` de `@nestjs/cqrs` con la versión (`loadFromHistory`, `commitAndWait`, `trackCommit`).
|
||||
- `Version`, `SerializableEvent`, `DomainEvent` (actor, fecha y `aggregateVersion`), `newId()`.
|
||||
- `EventClsRegistry` y `@AutowiredEvent`: registro de las clases de evento para deserializarlas.
|
||||
- Errores de dominio: `NotFoundError`, `ForbiddenError`, `ValidationError`, `BusinessRuleError`, `ConcurrencyError` y `assertThat`.
|
||||
|
||||
Event sourcing: [Arquitectura](../../../../docs/02-arquitectura.md#escritura-event-sourcing-en-postgresql).
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-shared-domain
|
||||
npx nx test api-shared-domain # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/shared/domain/eslint.config.mjs
Normal file
3
libs/api/shared/domain/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/shared/domain/jest.config.cts
Normal file
11
libs/api/shared/domain/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-shared-domain',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/api/shared/domain',
|
||||
};
|
||||
12
libs/api/shared/domain/project.json
Normal file
12
libs/api/shared/domain/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-shared-domain",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/shared/domain/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-shared",
|
||||
"type:domain"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-shared-domain --web",
|
||||
"targets": {}
|
||||
}
|
||||
6
libs/api/shared/domain/src/index.ts
Normal file
6
libs/api/shared/domain/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './lib/aggregate-root';
|
||||
export * from './lib/errors';
|
||||
export * from './lib/events/domain-event';
|
||||
export * from './lib/events/event-cls.registry';
|
||||
export * from './lib/interfaces/serializable-event';
|
||||
export * from './lib/value-objects/version';
|
||||
51
libs/api/shared/domain/src/lib/aggregate-root.ts
Normal file
51
libs/api/shared/domain/src/lib/aggregate-root.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { AggregateRoot } from '@nestjs/cqrs';
|
||||
import { SerializableEvent } from './interfaces/serializable-event';
|
||||
import { Version } from './value-objects/version';
|
||||
|
||||
const VERSION = Symbol('version');
|
||||
const PENDING_COMMIT = Symbol('pendingCommit');
|
||||
|
||||
export class VersionedAggregateRoot extends AggregateRoot {
|
||||
public id!: string;
|
||||
|
||||
private [VERSION] = new Version(0);
|
||||
private [PENDING_COMMIT]: Promise<unknown> = Promise.resolve();
|
||||
|
||||
get version(): Version {
|
||||
return this[VERSION];
|
||||
}
|
||||
|
||||
override loadFromHistory(history: SerializableEvent[]): void {
|
||||
const domainEvents = history.map((event) => event.data);
|
||||
super.loadFromHistory(domainEvents);
|
||||
|
||||
const lastEvent = history[history.length - 1];
|
||||
this.setVersion(new Version(lastEvent.position));
|
||||
}
|
||||
|
||||
/**
|
||||
* `commit()` from @nestjs/cqrs is fire-and-forget. This commits and waits
|
||||
* until the event store has persisted the events, so failures (e.g. a
|
||||
* concurrent modification) reach the command handler.
|
||||
*/
|
||||
async commitAndWait(): Promise<void> {
|
||||
this.commit();
|
||||
await this[PENDING_COMMIT];
|
||||
}
|
||||
|
||||
/** Called by the event-store publisher with the persistence of the committed events. */
|
||||
trackCommit(persisted: Promise<unknown>, eventCount: number): void {
|
||||
const from = this.version.value;
|
||||
const tracked = persisted.then(() =>
|
||||
this.setVersion(new Version(from + eventCount)),
|
||||
);
|
||||
// Marked as handled so a bare commit() never crashes the process;
|
||||
// commitAndWait() still receives the error.
|
||||
tracked.catch(() => undefined);
|
||||
this[PENDING_COMMIT] = tracked;
|
||||
}
|
||||
|
||||
private setVersion(version: Version): void {
|
||||
this[VERSION] = version;
|
||||
}
|
||||
}
|
||||
51
libs/api/shared/domain/src/lib/errors.ts
Normal file
51
libs/api/shared/domain/src/lib/errors.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type DomainErrorCode =
|
||||
| 'NOT_FOUND'
|
||||
| 'FORBIDDEN'
|
||||
| 'VALIDATION'
|
||||
| 'CONFLICT'
|
||||
| 'BUSINESS_RULE';
|
||||
|
||||
export abstract class DomainError extends Error {
|
||||
abstract readonly code: DomainErrorCode;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = new.target.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends DomainError {
|
||||
readonly code = 'NOT_FOUND' as const;
|
||||
|
||||
constructor(entity: string, id: string) {
|
||||
super(`${entity} "${id}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends DomainError {
|
||||
readonly code = 'FORBIDDEN' as const;
|
||||
}
|
||||
|
||||
export class ValidationError extends DomainError {
|
||||
readonly code = 'VALIDATION' as const;
|
||||
}
|
||||
|
||||
export class BusinessRuleError extends DomainError {
|
||||
readonly code = 'BUSINESS_RULE' as const;
|
||||
}
|
||||
|
||||
/** Optimistic-locking failure: someone else modified the aggregate first. */
|
||||
export class ConcurrencyError extends DomainError {
|
||||
readonly code = 'CONFLICT' as const;
|
||||
|
||||
constructor(entity: string, id: string) {
|
||||
super(`${entity} "${id}" was modified concurrently, retry the operation`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertThat(
|
||||
condition: unknown,
|
||||
message: string,
|
||||
): asserts condition {
|
||||
if (!condition) throw new ValidationError(message);
|
||||
}
|
||||
17
libs/api/shared/domain/src/lib/events/domain-event.ts
Normal file
17
libs/api/shared/domain/src/lib/events/domain-event.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/** Base class of every domain event: who did it and when. */
|
||||
export abstract class DomainEvent {
|
||||
readonly occurredAt: string = new Date().toISOString();
|
||||
/**
|
||||
* Position of the event in its aggregate stream. Set by the event store
|
||||
* when the event is persisted (undefined on freshly applied events).
|
||||
*/
|
||||
readonly aggregateVersion?: number;
|
||||
|
||||
constructor(readonly actorId: string) {}
|
||||
}
|
||||
|
||||
export function newId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
23
libs/api/shared/domain/src/lib/events/event-cls.registry.ts
Normal file
23
libs/api/shared/domain/src/lib/events/event-cls.registry.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Type } from '@nestjs/common';
|
||||
|
||||
/** Maps stored event type names back to their classes (for deserialization). */
|
||||
export class EventClsRegistry {
|
||||
private static readonly eventClsMap = new Map<string, Type>();
|
||||
|
||||
static add(eventCls: Type): void {
|
||||
this.eventClsMap.set(eventCls.name, eventCls);
|
||||
}
|
||||
|
||||
static get(eventClsName: string): Type {
|
||||
const eventCls = this.eventClsMap.get(eventClsName);
|
||||
if (!eventCls) {
|
||||
throw new Error(`Event class "${eventClsName}" not registered`);
|
||||
}
|
||||
return eventCls;
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers a domain event class so it can be rebuilt from the event store. */
|
||||
export const AutowiredEvent: ClassDecorator = (target) => {
|
||||
EventClsRegistry.add(target as unknown as Type);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Event payload as stored: class instances replaced by their JSON form. */
|
||||
export type SerializedEventPayload<T> = T extends object
|
||||
? {
|
||||
[K in keyof T]: T[K] extends { toJSON(): infer U }
|
||||
? U
|
||||
: SerializedEventPayload<T[K]>;
|
||||
}
|
||||
: T;
|
||||
|
||||
/** A domain event as persisted in the event store. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- payload type is open by design
|
||||
export interface SerializableEvent<T = any> {
|
||||
/** Aggregate id. */
|
||||
streamId: string;
|
||||
/** Event class name, e.g. `IssueCreatedEvent`. */
|
||||
type: string;
|
||||
/** 1-based position inside the stream = aggregate version after the event. */
|
||||
position: number;
|
||||
data: SerializedEventPayload<T>;
|
||||
}
|
||||
4
libs/api/shared/domain/src/lib/value-objects/version.ts
Normal file
4
libs/api/shared/domain/src/lib/value-objects/version.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Position of the last event applied to an aggregate (0 = brand new). */
|
||||
export class Version {
|
||||
constructor(readonly value: number) {}
|
||||
}
|
||||
23
libs/api/shared/domain/tsconfig.json
Normal file
23
libs/api/shared/domain/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
libs/api/shared/domain/tsconfig.lib.json
Normal file
15
libs/api/shared/domain/tsconfig.lib.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
16
libs/api/shared/domain/tsconfig.spec.json
Normal file
16
libs/api/shared/domain/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user