@archi-code/ddd 0.0.0-stage → 3.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +46 -0
- package/README.md +2693 -2
- package/SKILL.md +104 -0
- package/ddd-skills/archi-code-ddd/imports.md +84 -0
- package/ddd-skills/archi-code-ddd/layers.md +299 -0
- package/ddd-skills/archi-code-ddd/patterns.md +676 -0
- package/ddd-skills/archi-code-ddd/what-not-to-do.md +189 -0
- package/ddd-skills/archi-code-ddd/where-each-class-goes.md +63 -0
- package/ddd-skills/ddd-agregados/GUIA.md +114 -0
- package/ddd-skills/ddd-agregados/references/consistencia-eventual.md +159 -0
- package/ddd-skills/ddd-agregados/references/ejemplo-ecommerce.md +52 -0
- package/ddd-skills/ddd-agregados/references/fabricas.md +196 -0
- package/ddd-skills/ddd-agregados/references/implementacion-ts.md +711 -0
- package/ddd-skills/ddd-arquitectura/GUIA.md +80 -0
- package/ddd-skills/ddd-arquitectura/assets/dependency-cruiser.cjs +92 -0
- package/ddd-skills/ddd-arquitectura/references/estilos-adicionales.md +51 -0
- package/ddd-skills/ddd-arquitectura/references/estructura-hexagonal-ts.md +352 -0
- package/ddd-skills/ddd-arquitectura/references/modulos.md +48 -0
- package/ddd-skills/ddd-auditoria-refactorizacion/GUIA.md +85 -0
- package/ddd-skills/ddd-auditoria-refactorizacion/references/ejemplo-auditoria.md +316 -0
- package/ddd-skills/ddd-auditoria-refactorizacion/references/lista-comprobacion.md +54 -0
- package/ddd-skills/ddd-auditoria-refactorizacion/references/plan-migracion.md +73 -0
- package/ddd-skills/ddd-auditoria-refactorizacion/scripts/scan_ddd_smells.py +264 -0
- package/ddd-skills/ddd-context-mapping/GUIA.md +83 -0
- package/ddd-skills/ddd-context-mapping/references/anticorrupcion-ts.md +404 -0
- package/ddd-skills/ddd-context-mapping/references/estilos-integracion.md +155 -0
- package/ddd-skills/ddd-event-sourcing-cqrs/GUIA.md +92 -0
- package/ddd-skills/ddd-event-sourcing-cqrs/references/event-sourcing-ts.md +471 -0
- package/ddd-skills/ddd-event-sourcing-cqrs/references/proyecciones-cqrs-ts.md +285 -0
- package/ddd-skills/ddd-eventos-dominio/GUIA.md +106 -0
- package/ddd-skills/ddd-eventos-dominio/references/diseno-eventos.md +131 -0
- package/ddd-skills/ddd-eventos-dominio/references/ejemplo-ecommerce.md +240 -0
- package/ddd-skills/ddd-eventos-dominio/references/publicacion-ts.md +526 -0
- package/ddd-skills/ddd-orquestador/GUIA.md +40 -0
- package/ddd-skills/ddd-orquestador/references/chuleta-reglas.md +35 -0
- package/ddd-skills/ddd-repositorios-persistencia/GUIA.md +121 -0
- package/ddd-skills/ddd-repositorios-persistencia/references/implementacion-ts.md +296 -0
- package/ddd-skills/ddd-repositorios-persistencia/references/tests-repositorios.md +177 -0
- package/ddd-skills/ddd-repositorios-persistencia/references/transacciones-ts.md +294 -0
- package/ddd-skills/ddd-servicios-capa-aplicacion/GUIA.md +99 -0
- package/ddd-skills/ddd-servicios-capa-aplicacion/references/capa-aplicacion-ts.md +264 -0
- package/ddd-skills/ddd-servicios-capa-aplicacion/references/ejemplo-ecommerce.md +324 -0
- package/ddd-skills/ddd-servicios-capa-aplicacion/references/servicios-dominio-ts.md +191 -0
- package/ddd-skills/ddd-value-objects-entidades/GUIA.md +118 -0
- package/ddd-skills/ddd-value-objects-entidades/references/ejemplo-ecommerce.md +87 -0
- package/ddd-skills/ddd-value-objects-entidades/references/entidades-ts.md +220 -0
- package/ddd-skills/ddd-value-objects-entidades/references/persistencia-valores.md +88 -0
- package/ddd-skills/ddd-value-objects-entidades/references/specification-y-diseno-flexible.md +119 -0
- package/ddd-skills/ddd-value-objects-entidades/references/validacion.md +96 -0
- package/ddd-skills/ddd-value-objects-entidades/references/value-objects-ts.md +377 -0
- package/dist/cjs/application/event/event-bus.d.ts +4 -0
- package/dist/cjs/application/event/event-bus.js +2 -0
- package/dist/cjs/application/event/event-listener.d.ts +4 -0
- package/dist/cjs/application/event/event-listener.js +6 -0
- package/dist/cjs/application/event/listens-to.d.ts +15 -0
- package/dist/cjs/application/event/listens-to.js +28 -0
- package/dist/cjs/application/index.d.ts +7 -0
- package/dist/cjs/application/index.js +9 -0
- package/dist/cjs/application/ports/clock.d.ts +3 -0
- package/dist/cjs/application/ports/clock.js +2 -0
- package/dist/cjs/application/ports/logger.d.ts +8 -0
- package/dist/cjs/application/ports/logger.js +2 -0
- package/dist/cjs/application/use-case/use-case.d.ts +6 -0
- package/dist/cjs/application/use-case/use-case.js +24 -0
- package/dist/cjs/domain/aggregate/aggregate-root.d.ts +14 -0
- package/dist/cjs/domain/aggregate/aggregate-root.js +33 -0
- package/dist/cjs/domain/entity/entity.d.ts +9 -0
- package/dist/cjs/domain/entity/entity.js +19 -0
- package/dist/cjs/domain/event/event-base.d.ts +29 -0
- package/dist/cjs/domain/event/event-base.js +51 -0
- package/dist/cjs/domain/index.d.ts +10 -0
- package/dist/cjs/domain/index.js +24 -0
- package/dist/cjs/domain/primitive/primitive-types.d.ts +22 -0
- package/dist/cjs/domain/primitive/primitive-types.js +2 -0
- package/dist/cjs/domain/repository/aggregate-finder.d.ts +4 -0
- package/dist/cjs/domain/repository/aggregate-finder.js +2 -0
- package/dist/cjs/domain/repository/aggregate-remover.d.ts +4 -0
- package/dist/cjs/domain/repository/aggregate-remover.js +2 -0
- package/dist/cjs/domain/repository/aggregate-saver.d.ts +4 -0
- package/dist/cjs/domain/repository/aggregate-saver.js +2 -0
- package/dist/cjs/domain/specification/specification.d.ts +9 -0
- package/dist/cjs/domain/specification/specification.js +44 -0
- package/dist/cjs/domain/type/abstract-type.d.ts +20 -0
- package/dist/cjs/domain/type/abstract-type.js +69 -0
- package/dist/cjs/domain/type/array-type.d.ts +16 -0
- package/dist/cjs/domain/type/array-type.js +65 -0
- package/dist/cjs/domain/type/boolean-type.d.ts +4 -0
- package/dist/cjs/domain/type/boolean-type.js +16 -0
- package/dist/cjs/domain/type/composite/composite-field.d.ts +19 -0
- package/dist/cjs/domain/type/composite/composite-field.js +63 -0
- package/dist/cjs/domain/type/composite/composite.d.ts +26 -0
- package/dist/cjs/domain/type/composite/composite.js +55 -0
- package/dist/cjs/domain/type/date-type.d.ts +18 -0
- package/dist/cjs/domain/type/date-type.js +35 -0
- package/dist/cjs/domain/type/enum-type.d.ts +7 -0
- package/dist/cjs/domain/type/enum-type.js +30 -0
- package/dist/cjs/domain/type/equality/value-equality.d.ts +7 -0
- package/dist/cjs/domain/type/equality/value-equality.js +41 -0
- package/dist/cjs/domain/type/id-type.d.ts +7 -0
- package/dist/cjs/domain/type/id-type.js +17 -0
- package/dist/cjs/domain/type/immutable/immutable-date.d.ts +4 -0
- package/dist/cjs/domain/type/immutable/immutable-date.js +37 -0
- package/dist/cjs/domain/type/immutable/immutable.d.ts +4 -0
- package/dist/cjs/domain/type/immutable/immutable.js +19 -0
- package/dist/cjs/domain/type/index.d.ts +17 -0
- package/dist/cjs/domain/type/index.js +33 -0
- package/dist/cjs/domain/type/json-type.d.ts +5 -0
- package/dist/cjs/domain/type/json-type.js +17 -0
- package/dist/cjs/domain/type/number-type.d.ts +4 -0
- package/dist/cjs/domain/type/number-type.js +16 -0
- package/dist/cjs/domain/type/required/required-mark.d.ts +9 -0
- package/dist/cjs/domain/type/required/required-mark.js +13 -0
- package/dist/cjs/domain/type/required/required.d.ts +7 -0
- package/dist/cjs/domain/type/required/required.js +21 -0
- package/dist/cjs/domain/type/string-type.d.ts +4 -0
- package/dist/cjs/domain/type/string-type.js +16 -0
- package/dist/cjs/domain/type/uuid-type.d.ts +7 -0
- package/dist/cjs/domain/type/uuid-type.js +27 -0
- package/dist/cjs/domain/type/value-objects/value-objects.d.ts +16 -0
- package/dist/cjs/domain/type/value-objects/value-objects.js +70 -0
- package/dist/cjs/exceptions/abstract.exception.d.ts +23 -0
- package/dist/cjs/exceptions/abstract.exception.js +66 -0
- package/dist/cjs/exceptions/application/aggregate-not-found.exception.d.ts +5 -0
- package/dist/cjs/exceptions/application/aggregate-not-found.exception.js +17 -0
- package/dist/cjs/exceptions/application/application.exception.d.ts +6 -0
- package/dist/cjs/exceptions/application/application.exception.js +17 -0
- package/dist/cjs/exceptions/application/concurrency.exception.d.ts +5 -0
- package/dist/cjs/exceptions/application/concurrency.exception.js +17 -0
- package/dist/cjs/exceptions/application/not-allowed.exception.d.ts +5 -0
- package/dist/cjs/exceptions/application/not-allowed.exception.js +17 -0
- package/dist/cjs/exceptions/brand/exception-brand.d.ts +6 -0
- package/dist/cjs/exceptions/brand/exception-brand.js +29 -0
- package/dist/cjs/exceptions/domain/domain.exception.d.ts +6 -0
- package/dist/cjs/exceptions/domain/domain.exception.js +17 -0
- package/dist/cjs/exceptions/domain/required-value.exception.d.ts +4 -0
- package/dist/cjs/exceptions/domain/required-value.exception.js +21 -0
- package/dist/cjs/exceptions/domain/type-primitive.exception.d.ts +4 -0
- package/dist/cjs/exceptions/domain/type-primitive.exception.js +21 -0
- package/dist/cjs/exceptions/domain/validation.exception.d.ts +14 -0
- package/dist/cjs/exceptions/domain/validation.exception.js +23 -0
- package/dist/cjs/exceptions/exception-code.d.ts +15 -0
- package/dist/cjs/exceptions/exception-code.js +19 -0
- package/dist/cjs/exceptions/index.d.ts +14 -0
- package/dist/cjs/exceptions/index.js +30 -0
- package/dist/cjs/exceptions/infrastructure/event-delivery.exception.d.ts +5 -0
- package/dist/cjs/exceptions/infrastructure/event-delivery.exception.js +20 -0
- package/dist/cjs/exceptions/infrastructure/infrastructure.exception.d.ts +6 -0
- package/dist/cjs/exceptions/infrastructure/infrastructure.exception.js +17 -0
- package/dist/cjs/exceptions/infrastructure/internal-error.exception.d.ts +5 -0
- package/dist/cjs/exceptions/infrastructure/internal-error.exception.js +17 -0
- package/dist/cjs/exceptions/infrastructure/unknown-event.exception.d.ts +4 -0
- package/dist/cjs/exceptions/infrastructure/unknown-event.exception.js +19 -0
- package/dist/cjs/exceptions/received/received-value.d.ts +4 -0
- package/dist/cjs/exceptions/received/received-value.js +13 -0
- package/dist/cjs/infrastructure/clock/fixed-clock.d.ts +8 -0
- package/dist/cjs/infrastructure/clock/fixed-clock.js +19 -0
- package/dist/cjs/infrastructure/clock/system-clock.d.ts +4 -0
- package/dist/cjs/infrastructure/clock/system-clock.js +9 -0
- package/dist/cjs/infrastructure/event/domain-event-bus.d.ts +16 -0
- package/dist/cjs/infrastructure/event/domain-event-bus.js +60 -0
- package/dist/cjs/infrastructure/event/event-channel.d.ts +9 -0
- package/dist/cjs/infrastructure/event/event-channel.js +2 -0
- package/dist/cjs/infrastructure/event/event-registry.d.ts +9 -0
- package/dist/cjs/infrastructure/event/event-registry.js +43 -0
- package/dist/cjs/infrastructure/event/in-memory/emitter.d.ts +11 -0
- package/dist/cjs/infrastructure/event/in-memory/emitter.js +54 -0
- package/dist/cjs/infrastructure/event/in-memory/in-memory-event-channel.d.ts +16 -0
- package/dist/cjs/infrastructure/event/in-memory/in-memory-event-channel.js +51 -0
- package/dist/cjs/infrastructure/http/http-error-mapper.d.ts +23 -0
- package/dist/cjs/infrastructure/http/http-error-mapper.js +63 -0
- package/dist/cjs/infrastructure/index.d.ts +16 -0
- package/dist/cjs/infrastructure/index.js +21 -0
- package/dist/cjs/infrastructure/logging/console-logger.d.ts +17 -0
- package/dist/cjs/infrastructure/logging/console-logger.js +37 -0
- package/dist/cjs/infrastructure/logging/in-memory-logger.d.ts +15 -0
- package/dist/cjs/infrastructure/logging/in-memory-logger.js +27 -0
- package/dist/cjs/infrastructure/logging/log-serializer.d.ts +7 -0
- package/dist/cjs/infrastructure/logging/log-serializer.js +59 -0
- package/dist/cjs/infrastructure/logging/silent-logger.d.ts +7 -0
- package/dist/cjs/infrastructure/logging/silent-logger.js +18 -0
- package/dist/esm/application/event/event-bus.d.ts +4 -0
- package/dist/esm/application/event/event-bus.js +1 -0
- package/dist/esm/application/event/event-listener.d.ts +4 -0
- package/dist/esm/application/event/event-listener.js +2 -0
- package/dist/esm/application/event/listens-to.d.ts +15 -0
- package/dist/esm/application/event/listens-to.js +25 -0
- package/dist/esm/application/index.d.ts +7 -0
- package/dist/esm/application/index.js +3 -0
- package/dist/esm/application/ports/clock.d.ts +3 -0
- package/dist/esm/application/ports/clock.js +1 -0
- package/dist/esm/application/ports/logger.d.ts +8 -0
- package/dist/esm/application/ports/logger.js +1 -0
- package/dist/esm/application/use-case/use-case.d.ts +6 -0
- package/dist/esm/application/use-case/use-case.js +20 -0
- package/dist/esm/domain/aggregate/aggregate-root.d.ts +14 -0
- package/dist/esm/domain/aggregate/aggregate-root.js +29 -0
- package/dist/esm/domain/entity/entity.d.ts +9 -0
- package/dist/esm/domain/entity/entity.js +15 -0
- package/dist/esm/domain/event/event-base.d.ts +29 -0
- package/dist/esm/domain/event/event-base.js +47 -0
- package/dist/esm/domain/index.d.ts +10 -0
- package/dist/esm/domain/index.js +6 -0
- package/dist/esm/domain/primitive/primitive-types.d.ts +22 -0
- package/dist/esm/domain/primitive/primitive-types.js +1 -0
- package/dist/esm/domain/repository/aggregate-finder.d.ts +4 -0
- package/dist/esm/domain/repository/aggregate-finder.js +1 -0
- package/dist/esm/domain/repository/aggregate-remover.d.ts +4 -0
- package/dist/esm/domain/repository/aggregate-remover.js +1 -0
- package/dist/esm/domain/repository/aggregate-saver.d.ts +4 -0
- package/dist/esm/domain/repository/aggregate-saver.js +1 -0
- package/dist/esm/domain/specification/specification.d.ts +9 -0
- package/dist/esm/domain/specification/specification.js +40 -0
- package/dist/esm/domain/type/abstract-type.d.ts +20 -0
- package/dist/esm/domain/type/abstract-type.js +65 -0
- package/dist/esm/domain/type/array-type.d.ts +16 -0
- package/dist/esm/domain/type/array-type.js +61 -0
- package/dist/esm/domain/type/boolean-type.d.ts +4 -0
- package/dist/esm/domain/type/boolean-type.js +12 -0
- package/dist/esm/domain/type/composite/composite-field.d.ts +19 -0
- package/dist/esm/domain/type/composite/composite-field.js +59 -0
- package/dist/esm/domain/type/composite/composite.d.ts +26 -0
- package/dist/esm/domain/type/composite/composite.js +52 -0
- package/dist/esm/domain/type/date-type.d.ts +18 -0
- package/dist/esm/domain/type/date-type.js +29 -0
- package/dist/esm/domain/type/enum-type.d.ts +7 -0
- package/dist/esm/domain/type/enum-type.js +26 -0
- package/dist/esm/domain/type/equality/value-equality.d.ts +7 -0
- package/dist/esm/domain/type/equality/value-equality.js +37 -0
- package/dist/esm/domain/type/id-type.d.ts +7 -0
- package/dist/esm/domain/type/id-type.js +13 -0
- package/dist/esm/domain/type/immutable/immutable-date.d.ts +4 -0
- package/dist/esm/domain/type/immutable/immutable-date.js +33 -0
- package/dist/esm/domain/type/immutable/immutable.d.ts +4 -0
- package/dist/esm/domain/type/immutable/immutable.js +15 -0
- package/dist/esm/domain/type/index.d.ts +17 -0
- package/dist/esm/domain/type/index.js +14 -0
- package/dist/esm/domain/type/json-type.d.ts +5 -0
- package/dist/esm/domain/type/json-type.js +13 -0
- package/dist/esm/domain/type/number-type.d.ts +4 -0
- package/dist/esm/domain/type/number-type.js +12 -0
- package/dist/esm/domain/type/required/required-mark.d.ts +9 -0
- package/dist/esm/domain/type/required/required-mark.js +9 -0
- package/dist/esm/domain/type/required/required.d.ts +7 -0
- package/dist/esm/domain/type/required/required.js +18 -0
- package/dist/esm/domain/type/string-type.d.ts +4 -0
- package/dist/esm/domain/type/string-type.js +12 -0
- package/dist/esm/domain/type/uuid-type.d.ts +7 -0
- package/dist/esm/domain/type/uuid-type.js +23 -0
- package/dist/esm/domain/type/value-objects/value-objects.d.ts +16 -0
- package/dist/esm/domain/type/value-objects/value-objects.js +66 -0
- package/dist/esm/exceptions/abstract.exception.d.ts +23 -0
- package/dist/esm/exceptions/abstract.exception.js +62 -0
- package/dist/esm/exceptions/application/aggregate-not-found.exception.d.ts +5 -0
- package/dist/esm/exceptions/application/aggregate-not-found.exception.js +13 -0
- package/dist/esm/exceptions/application/application.exception.d.ts +6 -0
- package/dist/esm/exceptions/application/application.exception.js +13 -0
- package/dist/esm/exceptions/application/concurrency.exception.d.ts +5 -0
- package/dist/esm/exceptions/application/concurrency.exception.js +13 -0
- package/dist/esm/exceptions/application/not-allowed.exception.d.ts +5 -0
- package/dist/esm/exceptions/application/not-allowed.exception.js +13 -0
- package/dist/esm/exceptions/brand/exception-brand.d.ts +6 -0
- package/dist/esm/exceptions/brand/exception-brand.js +25 -0
- package/dist/esm/exceptions/domain/domain.exception.d.ts +6 -0
- package/dist/esm/exceptions/domain/domain.exception.js +13 -0
- package/dist/esm/exceptions/domain/required-value.exception.d.ts +4 -0
- package/dist/esm/exceptions/domain/required-value.exception.js +17 -0
- package/dist/esm/exceptions/domain/type-primitive.exception.d.ts +4 -0
- package/dist/esm/exceptions/domain/type-primitive.exception.js +17 -0
- package/dist/esm/exceptions/domain/validation.exception.d.ts +14 -0
- package/dist/esm/exceptions/domain/validation.exception.js +19 -0
- package/dist/esm/exceptions/exception-code.d.ts +15 -0
- package/dist/esm/exceptions/exception-code.js +16 -0
- package/dist/esm/exceptions/index.d.ts +14 -0
- package/dist/esm/exceptions/index.js +14 -0
- package/dist/esm/exceptions/infrastructure/event-delivery.exception.d.ts +5 -0
- package/dist/esm/exceptions/infrastructure/event-delivery.exception.js +16 -0
- package/dist/esm/exceptions/infrastructure/infrastructure.exception.d.ts +6 -0
- package/dist/esm/exceptions/infrastructure/infrastructure.exception.js +13 -0
- package/dist/esm/exceptions/infrastructure/internal-error.exception.d.ts +5 -0
- package/dist/esm/exceptions/infrastructure/internal-error.exception.js +13 -0
- package/dist/esm/exceptions/infrastructure/unknown-event.exception.d.ts +4 -0
- package/dist/esm/exceptions/infrastructure/unknown-event.exception.js +15 -0
- package/dist/esm/exceptions/received/received-value.d.ts +4 -0
- package/dist/esm/exceptions/received/received-value.js +9 -0
- package/dist/esm/infrastructure/clock/fixed-clock.d.ts +8 -0
- package/dist/esm/infrastructure/clock/fixed-clock.js +15 -0
- package/dist/esm/infrastructure/clock/system-clock.d.ts +4 -0
- package/dist/esm/infrastructure/clock/system-clock.js +5 -0
- package/dist/esm/infrastructure/event/domain-event-bus.d.ts +16 -0
- package/dist/esm/infrastructure/event/domain-event-bus.js +56 -0
- package/dist/esm/infrastructure/event/event-channel.d.ts +9 -0
- package/dist/esm/infrastructure/event/event-channel.js +1 -0
- package/dist/esm/infrastructure/event/event-registry.d.ts +9 -0
- package/dist/esm/infrastructure/event/event-registry.js +39 -0
- package/dist/esm/infrastructure/event/in-memory/emitter.d.ts +11 -0
- package/dist/esm/infrastructure/event/in-memory/emitter.js +50 -0
- package/dist/esm/infrastructure/event/in-memory/in-memory-event-channel.d.ts +16 -0
- package/dist/esm/infrastructure/event/in-memory/in-memory-event-channel.js +47 -0
- package/dist/esm/infrastructure/http/http-error-mapper.d.ts +23 -0
- package/dist/esm/infrastructure/http/http-error-mapper.js +59 -0
- package/dist/esm/infrastructure/index.d.ts +16 -0
- package/dist/esm/infrastructure/index.js +9 -0
- package/dist/esm/infrastructure/logging/console-logger.d.ts +17 -0
- package/dist/esm/infrastructure/logging/console-logger.js +33 -0
- package/dist/esm/infrastructure/logging/in-memory-logger.d.ts +15 -0
- package/dist/esm/infrastructure/logging/in-memory-logger.js +23 -0
- package/dist/esm/infrastructure/logging/log-serializer.d.ts +7 -0
- package/dist/esm/infrastructure/logging/log-serializer.js +55 -0
- package/dist/esm/infrastructure/logging/silent-logger.d.ts +7 -0
- package/dist/esm/infrastructure/logging/silent-logger.js +14 -0
- package/dist/esm/package.json +3 -0
- package/package.json +106 -4
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
# Recommended patterns
|
|
2
|
+
|
|
3
|
+
Part of the `archi-code-ddd` skill: read it only when that skill routes here.
|
|
4
|
+
|
|
5
|
+
Optional by default, required with `Required(XType)`:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
class Nickname extends StringType {} // value: string | null; constructor accepts string | null | nothing
|
|
9
|
+
class Name extends Required(StringType) {} // value: string; constructor demands string
|
|
10
|
+
class FullName extends Name {} // still required
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Always-valid value objects: `@Validations` rules run in the constructor:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
@Validations([{ validator: 'isEmail' }])
|
|
17
|
+
class Email extends Required(StringType) {}
|
|
18
|
+
|
|
19
|
+
@Validations([
|
|
20
|
+
{ validator: 'isInt', properties: { min: 0 } },
|
|
21
|
+
{ custom: 'isEven', fn: (v) => Number(v) % 2 === 0, message: 'Must be even' },
|
|
22
|
+
])
|
|
23
|
+
class PackSize extends NumberType {}
|
|
24
|
+
|
|
25
|
+
new Email('ana@example.com').value; // 'ana@example.com'
|
|
26
|
+
new Email('x');
|
|
27
|
+
// ValidationException: Validation failed, Email: Value does not satisfy isEmail
|
|
28
|
+
|
|
29
|
+
new PackSize(-3);
|
|
30
|
+
// ValidationException: Validation failed, PackSize: Value does not satisfy isInt, PackSize: Must be even
|
|
31
|
+
|
|
32
|
+
const command = ValueObjects.build({
|
|
33
|
+
email: () => new Email(input.email),
|
|
34
|
+
packSize: () => new PackSize(input.packSize),
|
|
35
|
+
});
|
|
36
|
+
// input { email: 'x', packSize: 3 } → one ValidationException with every field:
|
|
37
|
+
// errors: [
|
|
38
|
+
// { field: 'email', code: 'isEmail', message: 'Email: Value does not satisfy isEmail' },
|
|
39
|
+
// { field: 'packSize', code: 'isEven', message: 'PackSize: Must be even' },
|
|
40
|
+
// ]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Aggregate with typed id, a list of composite value objects and events:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
class OrderId extends IdType<'OrderId'> {}
|
|
47
|
+
class Sku extends Required(StringType) {}
|
|
48
|
+
class Quantity extends Required(NumberType) {}
|
|
49
|
+
class CustomerName extends Required(StringType) {}
|
|
50
|
+
class Shipped extends Required(BooleanType) {}
|
|
51
|
+
|
|
52
|
+
class OrderLine extends Composite({ sku: Sku, quantity: Quantity }) {}
|
|
53
|
+
|
|
54
|
+
class OrderPlaced extends EventBase {
|
|
55
|
+
static readonly eventName = 'OrderPlaced';
|
|
56
|
+
|
|
57
|
+
constructor(
|
|
58
|
+
orderId: string,
|
|
59
|
+
readonly customer: string,
|
|
60
|
+
metadata?: EventMetadata,
|
|
61
|
+
) {
|
|
62
|
+
super(orderId, metadata);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class OrderLineAdded extends EventBase {
|
|
67
|
+
static readonly eventName = 'OrderLineAdded';
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
orderId: string,
|
|
71
|
+
readonly sku: string,
|
|
72
|
+
readonly quantity: number,
|
|
73
|
+
metadata?: EventMetadata,
|
|
74
|
+
) {
|
|
75
|
+
super(orderId, metadata);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type OrderEvent = OrderPlaced | OrderLineAdded;
|
|
80
|
+
|
|
81
|
+
class OrderShippedException extends DomainException {
|
|
82
|
+
constructor(orderId: string) {
|
|
83
|
+
super(`Order ${orderId} is already shipped`, ['ORD001']);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class Order extends AggregateRoot<OrderId, OrderEvent> {
|
|
88
|
+
private constructor(
|
|
89
|
+
id: OrderId,
|
|
90
|
+
readonly customer: CustomerName,
|
|
91
|
+
private _lines: OrderLine[],
|
|
92
|
+
private _shipped: Shipped,
|
|
93
|
+
private _updatedAt: UpdatedAt,
|
|
94
|
+
readonly createdAt: CreatedAt,
|
|
95
|
+
) {
|
|
96
|
+
super(id);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static place(id: OrderId, customer: CustomerName, clock: { now(): Date }): Order {
|
|
100
|
+
const order = new Order(
|
|
101
|
+
id,
|
|
102
|
+
customer,
|
|
103
|
+
[],
|
|
104
|
+
new Shipped(false),
|
|
105
|
+
UpdatedAt.now(clock),
|
|
106
|
+
CreatedAt.now(clock),
|
|
107
|
+
);
|
|
108
|
+
order.record(new OrderPlaced(id.value, customer.value, { clock }));
|
|
109
|
+
return order;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
static fromPrimitives(primitives: PrimitiveTypes<Order>): Order {
|
|
113
|
+
return new Order(
|
|
114
|
+
new OrderId(primitives.id),
|
|
115
|
+
new CustomerName(primitives.customer),
|
|
116
|
+
primitives.lines.map((line) => OrderLine.fromPrimitives(line)),
|
|
117
|
+
new Shipped(primitives.shipped),
|
|
118
|
+
new UpdatedAt(primitives.updatedAt),
|
|
119
|
+
new CreatedAt(primitives.createdAt),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
get lines(): OrderLine[] {
|
|
124
|
+
return [...this._lines];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get shipped(): Shipped {
|
|
128
|
+
return this._shipped;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
get updatedAt(): UpdatedAt {
|
|
132
|
+
return this._updatedAt;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
addLine(sku: Sku, quantity: Quantity, clock: { now(): Date }): void {
|
|
136
|
+
if (this._shipped.value) {
|
|
137
|
+
throw new OrderShippedException(this.id.value);
|
|
138
|
+
}
|
|
139
|
+
this._lines = [...this._lines, new OrderLine({ sku, quantity })];
|
|
140
|
+
this._updatedAt = UpdatedAt.now(clock);
|
|
141
|
+
this.record(new OrderLineAdded(this.id.value, sku.value, quantity.value, { clock }));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
toPrimitives(): PrimitiveTypes<Order> {
|
|
145
|
+
return {
|
|
146
|
+
id: this.id.value,
|
|
147
|
+
customer: this.customer.value,
|
|
148
|
+
lines: this._lines.map((line) => line.toPrimitives()),
|
|
149
|
+
shipped: this.shipped.value,
|
|
150
|
+
updatedAt: this.updatedAt.value,
|
|
151
|
+
createdAt: this.createdAt.value,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Use case: the input becomes a command of value objects, then `handle` loads, changes, saves and
|
|
158
|
+
publishes:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
interface AddOrderLineInput {
|
|
162
|
+
orderId: string;
|
|
163
|
+
sku: string;
|
|
164
|
+
quantity: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface AddOrderLineCommand {
|
|
168
|
+
orderId: OrderId;
|
|
169
|
+
sku: Sku;
|
|
170
|
+
quantity: Quantity;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
class AddOrderLine extends UseCase<AddOrderLineInput, AddOrderLineCommand, void> {
|
|
174
|
+
constructor(
|
|
175
|
+
private readonly orders: AggregateFinder<Order> & AggregateSaver<Order>,
|
|
176
|
+
private readonly events: EventBus<OrderEvent>,
|
|
177
|
+
private readonly clock: Clock,
|
|
178
|
+
) {
|
|
179
|
+
super();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
protected toCommand(input: AddOrderLineInput): AddOrderLineCommand {
|
|
183
|
+
return ValueObjects.build({
|
|
184
|
+
orderId: () => new OrderId(input.orderId),
|
|
185
|
+
sku: () => new Sku(input.sku),
|
|
186
|
+
quantity: () => new Quantity(input.quantity),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
protected async handle(command: AddOrderLineCommand): Promise<void> {
|
|
191
|
+
const order = await this.orders.findById(command.orderId);
|
|
192
|
+
if (order === null) {
|
|
193
|
+
throw new AggregateNotFoundException('Order', command.orderId.value);
|
|
194
|
+
}
|
|
195
|
+
order.addLine(command.sku, command.quantity, this.clock);
|
|
196
|
+
await this.orders.save(order);
|
|
197
|
+
await this.events.publish(order.pullDomainEvents());
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Listener: named `<Action>On<Event>`, subscribed with `@ListensTo`, runs one use case:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
interface Warehouse {
|
|
206
|
+
prepare(orderId: OrderId, sku: Sku): Promise<void>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
class PrepareShipment extends UseCase<
|
|
210
|
+
{ orderId: string; sku: string },
|
|
211
|
+
{ orderId: OrderId; sku: Sku },
|
|
212
|
+
void
|
|
213
|
+
> {
|
|
214
|
+
constructor(private readonly warehouse: Warehouse) {
|
|
215
|
+
super();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
protected toCommand(input: { orderId: string; sku: string }): { orderId: OrderId; sku: Sku } {
|
|
219
|
+
return ValueObjects.build({
|
|
220
|
+
orderId: () => new OrderId(input.orderId),
|
|
221
|
+
sku: () => new Sku(input.sku),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
protected async handle(command: { orderId: OrderId; sku: Sku }): Promise<void> {
|
|
226
|
+
await this.warehouse.prepare(command.orderId, command.sku);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
@ListensTo(OrderLineAdded, { consumer: 'prepare-shipment-on-order-line-added' })
|
|
231
|
+
class PrepareShipmentOnOrderLineAdded extends EventListener<OrderLineAdded> {
|
|
232
|
+
constructor(private readonly prepareShipment: PrepareShipment) {
|
|
233
|
+
super();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async handle(event: OrderLineAdded): Promise<void> {
|
|
237
|
+
await this.prepareShipment.execute({ orderId: event.aggregateId, sku: event.sku });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
A real repository implements only the roles it supports, checks the version (also on `remove`,
|
|
243
|
+
which receives the loaded aggregate) and wraps driver errors with their cause:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
class MongoOrderRepository
|
|
247
|
+
implements AggregateFinder<Order>, AggregateSaver<Order>, AggregateRemover<Order>
|
|
248
|
+
{
|
|
249
|
+
constructor(private readonly collection: Collection<OrderDocument>) {}
|
|
250
|
+
|
|
251
|
+
async findById(id: OrderId): Promise<Order | null> {
|
|
252
|
+
const document = await this.collection.findOne({ _id: id.value });
|
|
253
|
+
if (document === null) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
const { _id, version, ...primitives } = document;
|
|
257
|
+
return Order.fromPrimitives(primitives).restoreVersion(version);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async save(order: Order): Promise<void> {
|
|
261
|
+
const data = { ...order.toPrimitives(), version: order.version + 1 };
|
|
262
|
+
let written: boolean;
|
|
263
|
+
try {
|
|
264
|
+
written =
|
|
265
|
+
order.version === 0
|
|
266
|
+
? await this.insertUnlessTaken(data)
|
|
267
|
+
: (await this.collection.replaceOne({ _id: data.id, version: order.version }, data))
|
|
268
|
+
.matchedCount === 1;
|
|
269
|
+
} catch (error) {
|
|
270
|
+
throw new InternalErrorException('saving order', { cause: error });
|
|
271
|
+
}
|
|
272
|
+
if (!written) {
|
|
273
|
+
throw new ConcurrencyException('Order', order.id.value, order.version);
|
|
274
|
+
}
|
|
275
|
+
order.markPersisted();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async remove(order: Order): Promise<void> {
|
|
279
|
+
const { deletedCount } = await this.collection.deleteOne({
|
|
280
|
+
_id: order.id.value,
|
|
281
|
+
version: order.version,
|
|
282
|
+
});
|
|
283
|
+
if (deletedCount === 0 && order.version > 0) {
|
|
284
|
+
throw new ConcurrencyException('Order', order.id.value, order.version);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
A repository per aggregate and technology: the constructor receives only its technology, the
|
|
291
|
+
aggregate converts itself and the repository calls it directly. The in-memory one
|
|
292
|
+
lives in the project's `test/support/`:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
import type {
|
|
296
|
+
AggregateFinder,
|
|
297
|
+
AggregateRemover,
|
|
298
|
+
AggregateSaver,
|
|
299
|
+
PrimitiveTypes,
|
|
300
|
+
} from '@archi-code/ddd/domain';
|
|
301
|
+
import { ConcurrencyException } from '@archi-code/ddd/exceptions';
|
|
302
|
+
|
|
303
|
+
export class InMemoryOrderRepository
|
|
304
|
+
implements AggregateFinder<Order>, AggregateSaver<Order>, AggregateRemover<Order>
|
|
305
|
+
{
|
|
306
|
+
private readonly rows = new Map<string, { version: number; primitives: PrimitiveTypes<Order> }>();
|
|
307
|
+
|
|
308
|
+
async findById(id: OrderId): Promise<Order | null> {
|
|
309
|
+
const row = this.rows.get(id.value);
|
|
310
|
+
return row === undefined
|
|
311
|
+
? null
|
|
312
|
+
: Order.fromPrimitives(structuredClone(row.primitives)).restoreVersion(row.version);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async save(order: Order): Promise<void> {
|
|
316
|
+
const key = this.currentKey(order);
|
|
317
|
+
const primitives = structuredClone(order.toPrimitives());
|
|
318
|
+
this.rows.set(key, { version: order.version + 1, primitives });
|
|
319
|
+
order.markPersisted();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async remove(order: Order): Promise<void> {
|
|
323
|
+
this.rows.delete(this.currentKey(order));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private currentKey(order: Order): string {
|
|
327
|
+
const key = order.id.value;
|
|
328
|
+
if ((this.rows.get(key)?.version ?? 0) !== order.version) {
|
|
329
|
+
throw new ConcurrencyException('Order', key, order.version);
|
|
330
|
+
}
|
|
331
|
+
return key;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Wiring and tests with the in-memory adapters (repository, event channel) and the HTTP mapper:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
const orders = new InMemoryOrderRepository();
|
|
340
|
+
const registry = new EventRegistry<OrderEvent>().register(
|
|
341
|
+
OrderLineAdded,
|
|
342
|
+
(json) => new OrderLineAdded(json.aggregateId, json.sku as string, json.quantity as number, json),
|
|
343
|
+
);
|
|
344
|
+
const channel = new InMemoryEventChannel(); // swap for your RabbitMQ or database channel
|
|
345
|
+
const bus = new DomainEventBus<OrderEvent>(channel, registry);
|
|
346
|
+
await bus.subscribe(new PrepareShipmentOnOrderLineAdded(new PrepareShipment(warehouse)));
|
|
347
|
+
|
|
348
|
+
const clock = new FixedClock('2026-03-01T09:30:00Z');
|
|
349
|
+
await orders.save(Order.place(new OrderId(orderId), new CustomerName('Ana'), clock));
|
|
350
|
+
await new AddOrderLine(orders, bus, clock).execute({
|
|
351
|
+
orderId,
|
|
352
|
+
sku: 'TORCH-01',
|
|
353
|
+
quantity: 2,
|
|
354
|
+
});
|
|
355
|
+
channel.sent.map((event) => event.eventName); // ['OrderLineAdded']: PrepareShipment already ran
|
|
356
|
+
|
|
357
|
+
const errors = new HttpErrorMapper({ statusByCode: { ORD001: 409 } }); // a domain rule defaults to 422
|
|
358
|
+
const { status, body } = errors.map(caughtError);
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
A test of the event flow asserts on what the listener caused and on what the channel carried,
|
|
362
|
+
never on the listener being called:
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
import { describe, expect, it } from 'vitest';
|
|
366
|
+
|
|
367
|
+
describe('Adding a line to an order', () => {
|
|
368
|
+
it('prepares the shipment of the new line', async () => {
|
|
369
|
+
const prepared: string[] = [];
|
|
370
|
+
const warehouse: Warehouse = {
|
|
371
|
+
prepare: async (orderId, sku) => void prepared.push(`${orderId.value}:${sku.value}`),
|
|
372
|
+
};
|
|
373
|
+
const orders = new InMemoryOrderRepository();
|
|
374
|
+
const channel = new InMemoryEventChannel();
|
|
375
|
+
const bus = new DomainEventBus<OrderEvent>(channel, registry);
|
|
376
|
+
await bus.subscribe(new PrepareShipmentOnOrderLineAdded(new PrepareShipment(warehouse)));
|
|
377
|
+
const clock = new FixedClock('2026-03-01T09:30:00Z');
|
|
378
|
+
await orders.save(Order.place(new OrderId(orderId), new CustomerName('Ana'), clock));
|
|
379
|
+
|
|
380
|
+
await new AddOrderLine(orders, bus, clock).execute({
|
|
381
|
+
orderId,
|
|
382
|
+
sku: 'TORCH-01',
|
|
383
|
+
quantity: 2,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
expect(prepared).toEqual([`${orderId}:TORCH-01`]);
|
|
387
|
+
expect(channel.sent).toMatchObject([
|
|
388
|
+
{ eventName: 'OrderLineAdded', aggregateId: orderId, sku: 'TORCH-01', quantity: 2 },
|
|
389
|
+
]);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Another messaging technology is one `EventChannel` class in `infrastructure/messaging/`; the
|
|
395
|
+
use cases, the listeners and `DomainEventBus` stay the same, and the composition root changes one
|
|
396
|
+
line (`new DomainEventBus(new RabbitMqEventChannel(amqpChannel), registry)`). A RabbitMQ channel
|
|
397
|
+
over the methods of an amqplib channel it uses, declared structurally: one durable topic exchange,
|
|
398
|
+
the event name as routing key, one durable queue per consumer (the `consumer` of `@ListensTo`) and event,
|
|
399
|
+
`ack` when the listener finished and `nack` without requeue when it failed (a dead-letter
|
|
400
|
+
exchange on the queue keeps those messages):
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
import type { ChannelHandler, ChannelSubscription, EventChannel } from '@archi-code/ddd/infrastructure';
|
|
404
|
+
import type { SerializedEvent } from '@archi-code/ddd/domain';
|
|
405
|
+
|
|
406
|
+
interface AmqpMessage {
|
|
407
|
+
content: Buffer;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
interface AmqpChannel {
|
|
411
|
+
assertExchange(exchange: string, type: 'topic', options: { durable: boolean }): Promise<unknown>;
|
|
412
|
+
publish(
|
|
413
|
+
exchange: string,
|
|
414
|
+
routingKey: string,
|
|
415
|
+
content: Buffer,
|
|
416
|
+
options: { persistent: boolean; messageId: string; contentType: string },
|
|
417
|
+
): boolean;
|
|
418
|
+
assertQueue(queue: string, options: { durable: boolean }): Promise<unknown>;
|
|
419
|
+
bindQueue(queue: string, exchange: string, pattern: string): Promise<unknown>;
|
|
420
|
+
consume(
|
|
421
|
+
queue: string,
|
|
422
|
+
onMessage: (message: AmqpMessage | null) => void,
|
|
423
|
+
): Promise<{ consumerTag: string }>;
|
|
424
|
+
ack(message: AmqpMessage): void;
|
|
425
|
+
nack(message: AmqpMessage, allUpTo: boolean, requeue: boolean): void;
|
|
426
|
+
cancel(consumerTag: string): Promise<unknown>;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
class RabbitMqEventChannel implements EventChannel {
|
|
430
|
+
constructor(
|
|
431
|
+
private readonly amqp: AmqpChannel,
|
|
432
|
+
private readonly exchange = 'sales.events',
|
|
433
|
+
) {}
|
|
434
|
+
|
|
435
|
+
async send(events: readonly SerializedEvent[]): Promise<void> {
|
|
436
|
+
await this.amqp.assertExchange(this.exchange, 'topic', { durable: true });
|
|
437
|
+
for (const event of events) {
|
|
438
|
+
this.amqp.publish(this.exchange, event.eventName, Buffer.from(JSON.stringify(event)), {
|
|
439
|
+
persistent: true,
|
|
440
|
+
messageId: event.eventId,
|
|
441
|
+
contentType: 'application/json',
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async receive(
|
|
447
|
+
eventName: string,
|
|
448
|
+
consumer: string,
|
|
449
|
+
handler: ChannelHandler,
|
|
450
|
+
): Promise<ChannelSubscription> {
|
|
451
|
+
const queue = `${consumer}.${eventName}`;
|
|
452
|
+
await this.amqp.assertExchange(this.exchange, 'topic', { durable: true });
|
|
453
|
+
await this.amqp.assertQueue(queue, { durable: true });
|
|
454
|
+
await this.amqp.bindQueue(queue, this.exchange, eventName);
|
|
455
|
+
const { consumerTag } = await this.amqp.consume(queue, (message) => {
|
|
456
|
+
if (message === null) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
handler(JSON.parse(message.content.toString()))
|
|
460
|
+
.then(() => this.amqp.ack(message))
|
|
461
|
+
.catch(() => this.amqp.nack(message, false, false));
|
|
462
|
+
});
|
|
463
|
+
let open = true;
|
|
464
|
+
return {
|
|
465
|
+
close: async () => {
|
|
466
|
+
if (open) {
|
|
467
|
+
open = false;
|
|
468
|
+
await this.amqp.cancel(consumerTag);
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
With a broker, `publish` resolves when RabbitMQ took the messages, not when the listener ran: an
|
|
477
|
+
integration test waits for the effect (polling until `prepared` has the line) instead of reading it
|
|
478
|
+
right after `execute`.
|
|
479
|
+
|
|
480
|
+
Tests with Vitest: describe real situations, use a plain fixture factory in the project's
|
|
481
|
+
`test/support/` and the native matchers (there is no archi-code test package). Dates held by VOs and
|
|
482
|
+
events are immutable `Date` subclasses: compare them with `toEqual` or `.toISOString()`, not
|
|
483
|
+
`toStrictEqual` against a plain `Date`.
|
|
484
|
+
|
|
485
|
+
```ts
|
|
486
|
+
import { describe, expect, it } from 'vitest';
|
|
487
|
+
|
|
488
|
+
const orderId = '9b2f6c1e-4d3a-4f8b-a1c2-3e4d5f6a7b8c';
|
|
489
|
+
|
|
490
|
+
describe('Order', () => {
|
|
491
|
+
it('a customer adds a torch to a new order and the warehouse is told about it', () => {
|
|
492
|
+
const clock = new FixedClock('2026-03-01T09:30:00Z');
|
|
493
|
+
const order = Order.place(new OrderId(orderId), new CustomerName('Ana'), clock);
|
|
494
|
+
order.addLine(new Sku('TORCH-01'), new Quantity(2), clock);
|
|
495
|
+
expect(order.toPrimitives()).toMatchObject({
|
|
496
|
+
id: orderId,
|
|
497
|
+
lines: [{ sku: 'TORCH-01', quantity: 2 }],
|
|
498
|
+
});
|
|
499
|
+
expect(order.pullDomainEvents().map((event) => event.toJSON())).toMatchObject([
|
|
500
|
+
{ eventName: 'OrderPlaced', aggregateId: orderId, customer: 'Ana' },
|
|
501
|
+
{ eventName: 'OrderLineAdded', sku: 'TORCH-01', quantity: 2 },
|
|
502
|
+
]);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
Composite value objects and specifications:
|
|
508
|
+
|
|
509
|
+
```ts
|
|
510
|
+
@Validations([{ validator: 'isInt' }])
|
|
511
|
+
class Cents extends Required(NumberType) {}
|
|
512
|
+
@Validations([{ validator: 'isISO4217' }])
|
|
513
|
+
class Currency extends Required(StringType) {
|
|
514
|
+
protected filter(value: unknown): string {
|
|
515
|
+
return super.filter(value).trim().toUpperCase();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
class CurrencyMismatch extends DomainException {
|
|
520
|
+
constructor(expected: string, received: string) {
|
|
521
|
+
super(`Cannot add ${received} to ${expected}`, ['PAY001']);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
class Money extends Composite({ amount: Cents, currency: Currency }) {
|
|
526
|
+
add(other: Money): Money {
|
|
527
|
+
if (!this.currency.equals(other.currency)) {
|
|
528
|
+
throw new CurrencyMismatch(this.currency.value, other.currency.value);
|
|
529
|
+
}
|
|
530
|
+
return new Money({
|
|
531
|
+
amount: new Cents(this.amount.value + other.amount.value),
|
|
532
|
+
currency: this.currency,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
class StartDate extends Required(DateType) {}
|
|
538
|
+
class EndDate extends Required(DateType) {}
|
|
539
|
+
|
|
540
|
+
@Validations([
|
|
541
|
+
{
|
|
542
|
+
custom: 'endsAfterItStarts',
|
|
543
|
+
fn: (range) => (range as Promotion).end.value >= (range as Promotion).start.value,
|
|
544
|
+
message: 'A promotion cannot end before it starts',
|
|
545
|
+
},
|
|
546
|
+
])
|
|
547
|
+
class Promotion extends Composite({ start: StartDate, end: EndDate }) {}
|
|
548
|
+
|
|
549
|
+
const price = Money.fromPrimitives({ amount: 1250, currency: ' eur ' });
|
|
550
|
+
price.toPrimitives(); // { amount: 1250, currency: 'EUR' }
|
|
551
|
+
price.equals(Money.fromPrimitives({ amount: 1250, currency: 'EUR' })); // true
|
|
552
|
+
|
|
553
|
+
class OrderAbove extends Specification<Order> {
|
|
554
|
+
constructor(private readonly minimumQuantity: number) {
|
|
555
|
+
super();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
isSatisfiedBy(order: Order): boolean {
|
|
559
|
+
return order.lines.reduce((sum, line) => sum + line.quantity.value, 0) >= this.minimumQuantity;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const freeShipping = new OrderAbove(10).and(new OrderAbove(100).not());
|
|
564
|
+
freeShipping.isSatisfiedBy(order);
|
|
565
|
+
|
|
566
|
+
const paidByCard: SatisfiedBy<Order> = { isSatisfiedBy: (candidate) => candidate.paidByCard() };
|
|
567
|
+
new OrderAbove(10).and(paidByCard); // any object with isSatisfiedBy combines too
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
A state as an enum value object: the primitive is the enum option, so the round trip needs no cast:
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
enum Status {
|
|
574
|
+
Pending = 'pending',
|
|
575
|
+
Shipped = 'shipped',
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
class StatusType extends EnumType<Status> {
|
|
579
|
+
protected getEnum(): typeof Status {
|
|
580
|
+
return Status;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
class OrderStatus extends Required(StatusType) {
|
|
585
|
+
isShipped(): boolean {
|
|
586
|
+
return this.value === Status.Shipped;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
new OrderStatus(primitives.status); // primitives.status: Status, from PrimitiveTypes<Order>
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
A use case run by a listener registers a fact: it never refuses it, and returns when the aggregate
|
|
594
|
+
is missing instead of making the channel redeliver forever:
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
class RegisterReservation extends UseCase<ReservationInput, ReservationCommand, void> {
|
|
598
|
+
// constructor and toCommand as in AddOrderLine
|
|
599
|
+
|
|
600
|
+
protected async handle(command: ReservationCommand): Promise<void> {
|
|
601
|
+
const order = await this.orders.findById(command.orderId);
|
|
602
|
+
if (order === null) {
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
order.registerStockReservation(command.reservationId, this.clock);
|
|
606
|
+
await this.orders.save(order);
|
|
607
|
+
await this.events.publish(order.pullDomainEvents());
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
A read query: an application interface returning DTOs, implemented in infrastructure on the
|
|
613
|
+
database, never through aggregates or repositories:
|
|
614
|
+
|
|
615
|
+
```ts
|
|
616
|
+
export interface OrderSummary {
|
|
617
|
+
readonly id: string;
|
|
618
|
+
readonly customer: string;
|
|
619
|
+
readonly lines: number;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export interface OrderSummaries {
|
|
623
|
+
ofCustomer(
|
|
624
|
+
customer: CustomerName,
|
|
625
|
+
page: { limit: number; offset: number },
|
|
626
|
+
): Promise<OrderSummary[]>;
|
|
627
|
+
}
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
One repository contract, run against the in-memory reference and the real adapter:
|
|
631
|
+
|
|
632
|
+
```ts
|
|
633
|
+
export function orderRepositoryContract(
|
|
634
|
+
make: () => Promise<AggregateFinder<Order> & AggregateSaver<Order> & AggregateRemover<Order>>,
|
|
635
|
+
): void {
|
|
636
|
+
const clock = new FixedClock('2026-03-01T09:30:00Z');
|
|
637
|
+
const placeOrder = () => Order.place(new OrderId(orderId), new CustomerName('Ana'), clock);
|
|
638
|
+
|
|
639
|
+
it('loads what it saved', async () => {
|
|
640
|
+
const orders = await make();
|
|
641
|
+
const order = placeOrder();
|
|
642
|
+
await orders.save(order);
|
|
643
|
+
expect((await orders.findById(order.id))?.toPrimitives()).toEqual(order.toPrimitives());
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('saves the same object twice', async () => {
|
|
647
|
+
const orders = await make();
|
|
648
|
+
const order = placeOrder();
|
|
649
|
+
await orders.save(order);
|
|
650
|
+
await expect(orders.save(order)).resolves.toBeUndefined();
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it('refuses a stale save and a new order with a taken id', async () => {
|
|
654
|
+
const orders = await make();
|
|
655
|
+
await orders.save(placeOrder());
|
|
656
|
+
const first = await orders.findById(new OrderId(orderId));
|
|
657
|
+
const second = await orders.findById(new OrderId(orderId));
|
|
658
|
+
await orders.save(first!);
|
|
659
|
+
await expect(orders.save(second!)).rejects.toThrow(ConcurrencyException);
|
|
660
|
+
await expect(orders.save(placeOrder())).rejects.toThrow(ConcurrencyException);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('refuses to remove an order someone changed after it was loaded', async () => {
|
|
664
|
+
const orders = await make();
|
|
665
|
+
await orders.save(placeOrder());
|
|
666
|
+
const first = await orders.findById(new OrderId(orderId));
|
|
667
|
+
const second = await orders.findById(new OrderId(orderId));
|
|
668
|
+
await orders.save(first!);
|
|
669
|
+
await expect(orders.remove(second!)).rejects.toThrow(ConcurrencyException);
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
describe('InMemoryOrderRepository', () =>
|
|
674
|
+
orderRepositoryContract(async () => new InMemoryOrderRepository()));
|
|
675
|
+
describe('MongoOrderRepository', () => orderRepositoryContract(async () => mongoOrders()));
|
|
676
|
+
```
|