@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
package/README.md
CHANGED
|
@@ -1,3 +1,2694 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @archi-code/ddd
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Domain-Driven Design building blocks for TypeScript, one entry point per layer:
|
|
4
|
+
|
|
5
|
+
| Entry point | Layer | What it gives you |
|
|
6
|
+
| -------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| `@archi-code/ddd/exceptions` | Shared | Exceptions of every layer with codes, causes and `instanceof` across builds |
|
|
8
|
+
| `@archi-code/ddd/domain` | Domain | Value objects, `Required(...)`, `Composite(...)`, `Immutable`, `Entity`, `AggregateRoot`, `EventBase`, `Specification` and the repository contracts (`AggregateFinder`, `AggregateSaver`, `AggregateRemover`) |
|
|
9
|
+
| `@archi-code/ddd/application` | Application | `UseCase`, the event contract (`EventListener`, `@ListensTo` to listen, `EventBus` to publish) and the ports `Clock` and `Logger` |
|
|
10
|
+
| `@archi-code/ddd/infrastructure` | Infrastructure | The event wiring (`DomainEventBus`, `EventRegistry`, the `EventChannel` port, `InMemoryEventChannel`), `HttpErrorMapper`, clocks, loggers |
|
|
11
|
+
|
|
12
|
+
The layers only depend inwards: `exceptions` imports nothing, `domain` imports only `exceptions`,
|
|
13
|
+
`application` imports `domain` and `exceptions`, and `infrastructure` imports all three. The
|
|
14
|
+
exceptions are a module of their own because every layer throws them: a use case wraps unknown
|
|
15
|
+
errors in an infrastructure exception, a use case throws `AggregateNotFoundException`. Import each file of your project from the layer it
|
|
16
|
+
belongs to and the same rule holds in your code (see [Architecture rules](#architecture-rules)). Conversions and validations come from
|
|
17
|
+
[`@archi-code/validation`](https://www.npmjs.com/package/@archi-code/validation). The package is
|
|
18
|
+
published with CJS and ESM builds.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install @archi-code/ddd
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- Node.js `>=24` (`engines` field).
|
|
27
|
+
- Runtime dependencies, installed by npm automatically: `@archi-code/validation` and `uuid`.
|
|
28
|
+
- To declare validations with `@Validations`, also install `@archi-code/validation` as a direct
|
|
29
|
+
dependency of your project (the decorator is imported from it):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install @archi-code/validation
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### TypeScript decorators
|
|
36
|
+
|
|
37
|
+
`@Validations` and `@ListensTo` work with both TypeScript decorator modes:
|
|
38
|
+
|
|
39
|
+
- **Standard** (TypeScript 5+ without `experimentalDecorators`): no configuration needed.
|
|
40
|
+
- **Legacy**: `"experimentalDecorators": true` in `tsconfig.json`.
|
|
41
|
+
|
|
42
|
+
It does not need `emitDecoratorMetadata` or `reflect-metadata`. If you declare no validations, no
|
|
43
|
+
decorator setting is needed at all.
|
|
44
|
+
|
|
45
|
+
## Import
|
|
46
|
+
|
|
47
|
+
There is no root entry point: import from the layer.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// ESM
|
|
51
|
+
import { AggregateRoot, EventBase, IdType, Required, StringType } from '@archi-code/ddd/domain';
|
|
52
|
+
import { DomainException, ExceptionCode } from '@archi-code/ddd/exceptions';
|
|
53
|
+
import type { AggregateSaver } from '@archi-code/ddd/domain';
|
|
54
|
+
import { EventListener, ListensTo, UseCase } from '@archi-code/ddd/application';
|
|
55
|
+
import type { EventBus } from '@archi-code/ddd/application';
|
|
56
|
+
import { HttpErrorMapper } from '@archi-code/ddd/infrastructure';
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
// CommonJS
|
|
61
|
+
const { AggregateRoot, IdType } = require('@archi-code/ddd/domain');
|
|
62
|
+
const { UseCase } = require('@archi-code/ddd/application');
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Types (`PrimitiveTypes`, `EventMetadata`, `AggregateSaver`…) are imported with `import type`. The
|
|
66
|
+
subpaths also resolve with `"moduleResolution": "node"` (through `typesVersions`).
|
|
67
|
+
|
|
68
|
+
Both builds can be loaded in the same process (for example a CommonJS application that uses an ESM
|
|
69
|
+
library built on this package): the exceptions of one build are `instanceof` the classes of the other
|
|
70
|
+
(see [Exceptions](#exceptions-archi-codedddexceptions)).
|
|
71
|
+
|
|
72
|
+
## Architecture rules
|
|
73
|
+
|
|
74
|
+
Every class of a project built on this package goes in exactly one layer. The table says where,
|
|
75
|
+
what it may import and what it must never do; the package itself is organized the same way.
|
|
76
|
+
|
|
77
|
+
| Class | Layer | Responsibility | May depend on | Never |
|
|
78
|
+
| ------------------------------------------------------------------------------ | -------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
79
|
+
| Value object (`extends StringType`…) | domain | One concept of the business; converts and validates its own value | other value objects, `@archi-code/validation`, exceptions | mutate, know about storage or transport |
|
|
80
|
+
| Aggregate (`extends AggregateRoot`) | domain | Business rules and invariants; records an event for every fact | its value objects, child entities, events, domain services, domain ports | read a repository, publish events, use `new Date()` |
|
|
81
|
+
| Composite value object (`Composite({...})`) | domain | Several values that only make sense together (money, a date range, an order line) | value objects, other composites | change, hold an id, be loaded or saved on its own |
|
|
82
|
+
| Child entity (`extends Entity`) | domain | A part of an aggregate with its own id; value objects and business methods only the root calls | value objects | setters, be called from outside its aggregate, be loaded or saved on its own |
|
|
83
|
+
| Specification (`extends Specification`) | domain | A named business rule that validates, selects or builds; combinable | value objects, aggregates, data already loaded | read a repository, protect an aggregate invariant |
|
|
84
|
+
| Domain event (`extends EventBase`) | domain | A fact that happened, named in the past (`OrderPlaced`, `'OrderPlaced'`), with primitive data | value objects (to take its primitives from them) | carry behavior or value objects, reference infrastructure |
|
|
85
|
+
| Repository contract (`AggregateFinder`…) | domain | Only gets and persists aggregates, one role per interface; no business logic | the aggregate | be implemented in the domain, hold rules, answer business queries |
|
|
86
|
+
| Domain service | domain | A business rule that spans several aggregates or fits none | aggregates, value objects, `AggregateFinder` (to read), domain ports | I/O, saving, transactions, publishing |
|
|
87
|
+
| Domain port (`ProductPricing`) | domain | What the model needs from another context or an external service, in its own words | value objects of this context | contain code, expose the other system's model |
|
|
88
|
+
| Use case (`extends UseCase`) | application | One business action: input → command of value objects → change one aggregate | domain, application ports, exceptions | import another use case or a listener, persist or call the network by itself |
|
|
89
|
+
| Listener (`extends EventListener`) | application | What happens after an event: turns it into the input of one use case; lives next to that use case | one use case (by constructor), the event | hold business rules, save aggregates, call several use cases |
|
|
90
|
+
| Application port (`EventBus`, `Clock`, `Logger`, your own such as `Warehouse`) | application | A technical capability the application needs, as a type | domain types | contain code |
|
|
91
|
+
| Read query (`OrderSummaries`) | application | An interface that answers a screen or a list with DTOs or ids, outside the aggregates | domain types, DTOs | load aggregates, go through a repository, change data |
|
|
92
|
+
| Adapter (repository, outbox, read query, clock…) | infrastructure | Implements a contract with a database, a queue, a broker, HTTP or memory | domain, application, exceptions, drivers | contain business rules |
|
|
93
|
+
| Anti-corruption layer (adapter + translator) | infrastructure | Implements a domain port by calling another context or service and translating its model into ours | domain, application, exceptions, clients | leak the other model into the domain, hold business rules |
|
|
94
|
+
| Entry point (controller, message consumer, CLI) | interfaces | Turns HTTP, a message or a command line into the input of a use case, and back | application, exceptions, `HttpErrorMapper` (domain types only if unavoidable) | hold business rules, use a repository or another adapter directly |
|
|
95
|
+
| Composition root (`composition.ts`) | (per context) | Reads the configuration, builds adapters, use cases and entry points | everything | be imported by the domain, the application or the interfaces |
|
|
96
|
+
|
|
97
|
+
Dependencies only point inwards. Your domain imports only `@archi-code/ddd/domain`,
|
|
98
|
+
`@archi-code/ddd/exceptions` and `@archi-code/validation`; the application adds
|
|
99
|
+
`@archi-code/ddd/application`; infrastructure implements the contracts of both; and `interfaces`
|
|
100
|
+
(controllers, consumers) only calls the application. The application never imports
|
|
101
|
+
infrastructure, the domain never imports the application, and an entry point never reaches a
|
|
102
|
+
repository or an adapter: only the composition root knows every piece [IDDD ch. 4]. The one
|
|
103
|
+
infrastructure import an entry point may have is `HttpErrorMapper` (or it receives the mapper from
|
|
104
|
+
the composition root). A controller validates only the shape of the request, maps the use case
|
|
105
|
+
result (an id, a DTO or nothing) to the response and the errors with `HttpErrorMapper`; it never
|
|
106
|
+
returns an aggregate.
|
|
107
|
+
|
|
108
|
+
### Folder structure
|
|
109
|
+
|
|
110
|
+
One folder per bounded context; inside it the four zones, and inside each zone **modules named
|
|
111
|
+
after the ubiquitous language**, never after patterns (`order/`, not `entities/`, `events/`,
|
|
112
|
+
`valueobjects/`) [IDDD ch. 9]:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
src/
|
|
116
|
+
contexts/
|
|
117
|
+
sales/
|
|
118
|
+
domain/
|
|
119
|
+
order/ Order.ts, OrderLine.ts, OrderId.ts, events.ts, OrderStatus.ts
|
|
120
|
+
customer/ CustomerId.ts
|
|
121
|
+
catalog/ ProductPricing.ts (domain port to the catalog context)
|
|
122
|
+
shared/ Money.ts (shared inside this context only)
|
|
123
|
+
application/
|
|
124
|
+
place-order/ PlaceOrder.ts
|
|
125
|
+
ship-order/ ShipOrder.ts
|
|
126
|
+
notify-customer/ NotifyCustomer.ts, NotifyCustomerOnOrderShipped.ts
|
|
127
|
+
order-summaries/ OrderSummaries.ts (read query: interface + DTOs)
|
|
128
|
+
infrastructure/
|
|
129
|
+
persistence/ MongoOrderRepository.ts, MongoOrderSummaries.ts
|
|
130
|
+
messaging/ RabbitMqEventChannel.ts
|
|
131
|
+
catalog/ CatalogHttpProductPricing.ts, CatalogTranslator.ts (anti-corruption layer)
|
|
132
|
+
interfaces/
|
|
133
|
+
http/ ordersRouter.ts
|
|
134
|
+
messaging/ StockReservedConsumer.ts
|
|
135
|
+
composition.ts composition root of the context
|
|
136
|
+
warehouse/
|
|
137
|
+
shared-kernel/ optional: the few types two contexts agree to share and change together
|
|
138
|
+
main.ts
|
|
139
|
+
test/ mirrors src/
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- A listener lives in the module of the use case it runs (`notify-customer/`), not in a
|
|
143
|
+
`listeners/` folder.
|
|
144
|
+
- A context never imports another one: they integrate through published events, an API or an
|
|
145
|
+
agreed shared kernel (`shared-kernel/`, kept small, changed only by agreement of both teams).
|
|
146
|
+
- Check the direction of the imports in CI with dependency-cruiser. The DDD suite shipped with the
|
|
147
|
+
package has a ready configuration to start from:
|
|
148
|
+
`node_modules/@archi-code/ddd/ddd-skills/ddd-arquitectura/assets/dependency-cruiser.cjs` (domain
|
|
149
|
+
without npm packages but the allowed ones, no imports from `application`, `infrastructure` or
|
|
150
|
+
`interfaces` into the domain, none from `infrastructure` or `interfaces` into the application, no
|
|
151
|
+
`@archi-code/ddd/infrastructure` in the domain or the application, none from `interfaces` into
|
|
152
|
+
your `infrastructure` (`HttpErrorMapper` comes from the package), isolated contexts, no cycles,
|
|
153
|
+
and `tsPreCompilationDeps: true` so type imports count too). Adapt its paths to your layout.
|
|
154
|
+
- Environment variables are read only in the composition root and reach the adapters as typed
|
|
155
|
+
configuration; the domain never logs nor reads `process.env`.
|
|
156
|
+
|
|
157
|
+
### Design rules
|
|
158
|
+
|
|
159
|
+
- **A use case returns an id, a DTO or nothing**, never an aggregate: the caller must not depend
|
|
160
|
+
on the model [IDDD ch. 4 and 14]. Screens that read several aggregates use a read query or a read
|
|
161
|
+
model, not repositories.
|
|
162
|
+
- **Another aggregate is referenced by its id** (`customerId: CustomerId`), never by the object;
|
|
163
|
+
copy into a value object only the little data the aggregate needs [IDDD ch. 10].
|
|
164
|
+
- **A command can be refused, a fact cannot** [Destilado ch. 6]: a method that registers something
|
|
165
|
+
that already happened in another aggregate or context (`registerStockReservation`) is idempotent
|
|
166
|
+
and does not throw a business error; if the fact does not fit, it records an event (for example
|
|
167
|
+
`PaymentAuthorizationInsufficient`) so another listener compensates. Business exceptions are for
|
|
168
|
+
commands a user can be told about.
|
|
169
|
+
- **Money is never a `NumberType`**: model it as a composite (`Money({ amount: Cents, currency })`)
|
|
170
|
+
with whole minor units, and decide the rounding of every division explicitly.
|
|
171
|
+
- **Repositories only get and persist**: `findById`, `save`, `remove`, with no business rule and no
|
|
172
|
+
business query. Ids come from the value object (`OrderId.generate()`), not from the repository.
|
|
173
|
+
Lists and searches for screens are read queries of the application (SQL, a view, a read model),
|
|
174
|
+
outside the repositories.
|
|
175
|
+
- **Child entities behave, through their root**: a child entity holds its id and value objects and
|
|
176
|
+
may have business methods named after the language (`seat.relabel(label)`), never setters; only
|
|
177
|
+
its aggregate root calls them (Tell, Don't Ask), and the root keeps the rules that span several
|
|
178
|
+
parts [IDDD ch. 5 and 10] (see [Aggregates and domain events](#aggregates-and-domain-events)).
|
|
179
|
+
- **Derived values are methods, never getters** (`order.total()`, not `get total()`), on
|
|
180
|
+
aggregates, child entities and composites alike: a getter becomes a key of `PrimitiveTypes` and
|
|
181
|
+
`toPrimitives()` no longer matches the stored data.
|
|
182
|
+
- **Event names**: an event's `eventName` is its class name, a fact in the past tense
|
|
183
|
+
(`'OrderPlaced'`) [IDDD ch. 8]; contracts published to other contexts are
|
|
184
|
+
`'<context>.<module>.<Event>'` (`'sales.order.OrderPlaced'`) [IDDD ch. 13]. An event carries
|
|
185
|
+
primitives taken from value objects (ids as strings, money as `{ amount, currency }`), never value
|
|
186
|
+
objects nor the whole state of the aggregate.
|
|
187
|
+
|
|
188
|
+
Logic that two use cases share never goes in a use case: a business rule becomes a method of the
|
|
189
|
+
aggregate or a domain service, and a technical step becomes a port. Use cases never call each
|
|
190
|
+
other: when an action must follow another, the first records an event and a listener runs the
|
|
191
|
+
second.
|
|
192
|
+
|
|
193
|
+
Naming: use cases are a verb and a noun of the business (`PlaceOrder`, `ShipOrder`), never CRUD
|
|
194
|
+
or technical names (`UpdateOrder`, `OrderService`); events are facts in the past
|
|
195
|
+
(`OrderPlaced`, `PaymentCaptured`); listeners are `<Action>On<Event>`
|
|
196
|
+
(`ReserveStockOnOrderPlaced`), where `<Action>` names the use case they run.
|
|
197
|
+
|
|
198
|
+
# Domain layer: `@archi-code/ddd/domain`
|
|
199
|
+
|
|
200
|
+
## Value objects
|
|
201
|
+
|
|
202
|
+
Each value object wraps a value of one type and converts the input when it is constructed:
|
|
203
|
+
|
|
204
|
+
| Class | Value | Accepts |
|
|
205
|
+
| ----------------- | --------- | -------------------------------------------------------------------------------------- |
|
|
206
|
+
| `StringType` | `string` | strings, finite numbers and booleans |
|
|
207
|
+
| `NumberType` | `number` | finite numbers and decimal strings (`'1.5'`, `' 2 '`, `'1e3'`) |
|
|
208
|
+
| `BooleanType` | `boolean` | booleans, `1`/`0` and `'true'`/`'false'`/`'1'`/`'0'` (case-insensitive) |
|
|
209
|
+
| `DateType` | `Date` | valid `Date` instances, ISO dates (UTC midnight) and ISO date-times with a zone |
|
|
210
|
+
| `UuidType` | `string` | UUID strings, stored in lowercase |
|
|
211
|
+
| `EnumType<T>` | `T` | one option of the enum returned by `getEnum()` (abstract) |
|
|
212
|
+
| `JsonType<T>` | `T` | non-empty JSON objects and strings that contain one |
|
|
213
|
+
| `ArrayType<Item>` | `Item[]` | arrays (or strings with a JSON array); each item is built by `createItem()` (abstract) |
|
|
214
|
+
| `CreatedAt` | `Date` | like `DateType`, required; `CreatedAt.now()` |
|
|
215
|
+
| `UpdatedAt` | `Date` | like `DateType`, required; `UpdatedAt.now()` |
|
|
216
|
+
| `IdType` | `string` | like `UuidType`, required, and rejects the nil and max UUIDs |
|
|
217
|
+
|
|
218
|
+
The constructor is typed with the value's type (`new NumberType(1.5)`); `DateType` (and
|
|
219
|
+
`CreatedAt`/`UpdatedAt`) also takes ISO text (`Date | string`), because dates travel as text in
|
|
220
|
+
JSON and forms. The conversion acts at runtime on data that arrives untyped, such as a JSON body:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import { BooleanType, DateType, NumberType, StringType } from '@archi-code/ddd/domain';
|
|
224
|
+
|
|
225
|
+
const body = JSON.parse(
|
|
226
|
+
'{"price":"1.5","code":12,"active":"TRUE","at":"2024-01-31T10:00:00+02:00"}',
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
new NumberType(body.price).value; // 1.5
|
|
230
|
+
new StringType(body.code).value; // '12'
|
|
231
|
+
new BooleanType(body.active).value; // true
|
|
232
|
+
new DateType(body.at).toString(); // '2024-01-31T08:00:00.000Z'
|
|
233
|
+
new DateType('2018-03-23').toString(); // '2018-03-23T00:00:00.000Z'
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
A value that cannot be converted throws `TypePrimitiveException`:
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
new NumberType('abc' as any);
|
|
240
|
+
// TypePrimitiveException: Validation Error: Expected a valid Number, but received "abc".
|
|
241
|
+
new NumberType(Infinity);
|
|
242
|
+
// TypePrimitiveException: Validation Error: Expected a valid Number, but received Infinity.
|
|
243
|
+
new BooleanType('yes' as any);
|
|
244
|
+
// TypePrimitiveException: Validation Error: Expected a valid Boolean, but received "yes".
|
|
245
|
+
new DateType('31/01/2024');
|
|
246
|
+
// TypePrimitiveException: Validation Error: Expected a valid Date, but received "31/01/2024".
|
|
247
|
+
new DateType('2024-01-31T10:00:00');
|
|
248
|
+
// TypePrimitiveException: Validation Error: Expected a valid Date, but received "2024-01-31T10:00:00".
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
A date-time without a zone is rejected because it does not say which instant it is (local time of
|
|
252
|
+
the browser? of the server?). Send it with `Z` or an offset, or send only the date.
|
|
253
|
+
|
|
254
|
+
### What each value object exposes
|
|
255
|
+
|
|
256
|
+
| Member | Type | Description |
|
|
257
|
+
| --------------- | --------------------- | --------------------------------------------------------------------------------------- |
|
|
258
|
+
| `value` | `T \| null` (or `T`) | The converted value; `T` if the type is required (`Required`) |
|
|
259
|
+
| `isNull` | `boolean` | `true` if there is no value |
|
|
260
|
+
| `toString()` | `string` | Text of the value; `''` if it is `null`. `DateType` gives ISO 8601, `JsonType` the JSON |
|
|
261
|
+
| `toJSON()` | `T \| null` (or `T`) | The value (a date as its ISO `string`), so `JSON.stringify` writes the plain value |
|
|
262
|
+
| `equals(other)` | `boolean` | Same class and same value (dates by instant, arrays and objects by content) |
|
|
263
|
+
| `validate()` | `ValidationResult<T>` | Runs the `@Validations` rules again; never throws (always ok on a built value object) |
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
class City extends StringType {}
|
|
267
|
+
class Country extends StringType {}
|
|
268
|
+
|
|
269
|
+
const city = new City('Huelva');
|
|
270
|
+
`Shipping to ${city}`; // 'Shipping to Huelva'
|
|
271
|
+
JSON.stringify({ city }); // '{"city":"Huelva"}'
|
|
272
|
+
city.equals(new City('Huelva')); // true
|
|
273
|
+
city.equals(new Country('Huelva')); // false: another concept, even with the same text
|
|
274
|
+
new NumberType().toString(); // ''
|
|
275
|
+
new StringType(' ').value; // null (blank strings count as empty)
|
|
276
|
+
new StringType(null).isNull; // true
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Optional by default, required with `Required(...)`
|
|
280
|
+
|
|
281
|
+
A value object is optional: it accepts the value, `null` or nothing, and holds `T | null`. Wrap it
|
|
282
|
+
with `Required(...)` to make it required: the constructor asks for exactly `T`, `value` is typed as
|
|
283
|
+
`T` and an empty value throws `RequiredValueException` on construction.
|
|
284
|
+
|
|
285
|
+
This is a convention of this package, not of the books: they build every value object with a
|
|
286
|
+
value and model an optional one as a field that may be `null` (`reservation: ReservationId | null`)
|
|
287
|
+
[IDDD ch. 5 and 6]. Both forms work here; an optional value object never holds an identity (ids
|
|
288
|
+
are required, see [Aggregates and domain events](#aggregates-and-domain-events)), and an optional
|
|
289
|
+
composite is always a `Money | null` field.
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
import { Required, StringType } from '@archi-code/ddd/domain';
|
|
293
|
+
|
|
294
|
+
class Name extends Required(StringType) {}
|
|
295
|
+
class Nickname extends StringType {}
|
|
296
|
+
|
|
297
|
+
const name: string = new Name('Ana').value; // 'Ana'
|
|
298
|
+
const nickname: string | null = new Nickname().value; // null
|
|
299
|
+
|
|
300
|
+
new Name(null); // type error and, at runtime:
|
|
301
|
+
// RequiredValueException: Validation Error: Name is required, but received null.
|
|
302
|
+
new Name(' ');
|
|
303
|
+
// RequiredValueException: Validation Error: Name is required, but received " ".
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
`null`, `undefined` and blank strings count as empty in every type. A subclass of a required value
|
|
307
|
+
object is also required (`class FullName extends Name {}`). `Required(...)` accepts any subclass of
|
|
308
|
+
`AbstractType`, including your own and abstract ones (`Required(StatusType)`, `Required(Tags)`).
|
|
309
|
+
|
|
310
|
+
`Required(...)` also declares the `isRequired` validation, so the rules of a required type include
|
|
311
|
+
it too. Its class is named `Required(<type>)` (`Required(StringType)`), so the messages of a
|
|
312
|
+
required value built inline from plain JavaScript still name the type it requires.
|
|
313
|
+
|
|
314
|
+
### Validations: always valid from the constructor
|
|
315
|
+
|
|
316
|
+
Validations are declared with `@Validations` from `@archi-code/validation` (that package's `is*`
|
|
317
|
+
validators or `custom` validations with their own function). The constructor runs them after the
|
|
318
|
+
conversion, so **a value object that exists is valid**: there is no invalid `Email` to pass around,
|
|
319
|
+
whether it was built by a use case, an aggregate, `fromPrimitives` or a test. When rules are broken
|
|
320
|
+
it throws `ValidationException` with **every** broken rule, each prefixed with the class name. An
|
|
321
|
+
optional, empty value object is built without running its rules.
|
|
322
|
+
|
|
323
|
+
Each entry of `error.errors` is a `ValidationIssue`, `{ field, code, message }`: `field` is the path
|
|
324
|
+
of the input (`null` for the value object itself), and `code` says which rule failed so a client
|
|
325
|
+
can translate it without parsing the message: the validator name (`'isEmail'`), the name of a
|
|
326
|
+
`custom` rule (`'isEven'`), or the code of the exception a value threw (`'DOM003'` for
|
|
327
|
+
`TypePrimitiveException`, `'DOM004'` for `RequiredValueException`, `'ORD003'` for your own
|
|
328
|
+
`DomainException` with that code).
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
import { Validations } from '@archi-code/validation';
|
|
332
|
+
import { NumberType, Required, StringType } from '@archi-code/ddd/domain';
|
|
333
|
+
|
|
334
|
+
@Validations([{ validator: 'isEmail' }])
|
|
335
|
+
class Email extends Required(StringType) {}
|
|
336
|
+
|
|
337
|
+
new Email('ana@example.com').value; // 'ana@example.com'
|
|
338
|
+
new Email('x');
|
|
339
|
+
// ValidationException: Validation failed, Email: Value does not satisfy isEmail
|
|
340
|
+
// error.errors: [{ field: null, code: 'isEmail', message: 'Email: Value does not satisfy isEmail' }]
|
|
341
|
+
|
|
342
|
+
@Validations([{ validator: 'isLength', properties: { min: 3, max: 20 } }])
|
|
343
|
+
class Username extends StringType {}
|
|
344
|
+
|
|
345
|
+
new Username().value; // null: an optional, empty value runs no rule
|
|
346
|
+
|
|
347
|
+
@Validations([
|
|
348
|
+
{ validator: 'isInt', properties: { min: 0 } },
|
|
349
|
+
{ custom: 'isEven', fn: (v) => Number(v) % 2 === 0, message: 'Must be even' },
|
|
350
|
+
])
|
|
351
|
+
class Quantity extends NumberType {}
|
|
352
|
+
|
|
353
|
+
new Quantity(-3);
|
|
354
|
+
// ValidationException: Validation failed, Quantity: Value does not satisfy isInt, Quantity: Must be even
|
|
355
|
+
// error.errors:
|
|
356
|
+
// [
|
|
357
|
+
// { field: null, code: 'isInt', message: 'Quantity: Value does not satisfy isInt' },
|
|
358
|
+
// { field: null, code: 'isEven', message: 'Quantity: Must be even' },
|
|
359
|
+
// ]
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
A subclass inherits its parent's validations; if it declares the same validation, it replaces it.
|
|
363
|
+
Validator names and their `properties` are in the `@archi-code/validation` documentation.
|
|
364
|
+
`validate()` runs the rules again and returns a frozen `{ ok, value, errors }` without throwing;
|
|
365
|
+
on a value object that was built it is always `ok`.
|
|
366
|
+
|
|
367
|
+
### Building every value object of an input at once: `ValueObjects.build`
|
|
368
|
+
|
|
369
|
+
The constructor of each value object stops at its own errors. To report **every field** of an
|
|
370
|
+
input (a form, a JSON body, a message) at once, build the value objects with
|
|
371
|
+
`ValueObjects.build`: it runs every factory, even after one fails, and throws one
|
|
372
|
+
`ValidationException` whose `errors` name the field of each problem. It collects the three ways a
|
|
373
|
+
value can be wrong: empty in a required type (`RequiredValueException`), not convertible
|
|
374
|
+
(`TypePrimitiveException`) and broken rules (`ValidationException`), plus any other
|
|
375
|
+
`DomainException` a factory throws. Any other error is a bug and goes through untouched, and it
|
|
376
|
+
wins: the validation errors already collected are dropped, so a bug is never hidden behind a 422.
|
|
377
|
+
`ValueObjects.buildList` does the same for a list and names each item by its position, so nested
|
|
378
|
+
inputs get paths such as `lines.1.quantity`. Factories are synchronous: a factory that returns a
|
|
379
|
+
Promise does not compile in `build`, and both methods refuse it at run time with
|
|
380
|
+
`InternalErrorException` (`ValueObjects: the factory of "email" returned a Promise; build value
|
|
381
|
+
objects synchronously`), so an asynchronous check never skips the validation. Every field name is
|
|
382
|
+
kept as written, `__proto__` included.
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import { IdType, NumberType, Required, StringType, ValueObjects } from '@archi-code/ddd/domain';
|
|
386
|
+
import { Validations } from '@archi-code/validation';
|
|
387
|
+
|
|
388
|
+
// Email as in the previous block; input is the raw request body
|
|
389
|
+
|
|
390
|
+
class OrderId extends IdType<'OrderId'> {}
|
|
391
|
+
class Sku extends Required(StringType) {}
|
|
392
|
+
|
|
393
|
+
@Validations([
|
|
394
|
+
{ validator: 'isInt', properties: { min: 1, max: 20 }, message: 'From 1 to 20 units' },
|
|
395
|
+
])
|
|
396
|
+
class Quantity extends Required(NumberType) {}
|
|
397
|
+
|
|
398
|
+
const command = ValueObjects.build({
|
|
399
|
+
orderId: () => new OrderId(input.orderId),
|
|
400
|
+
email: () => new Email(input.email),
|
|
401
|
+
lines: () =>
|
|
402
|
+
ValueObjects.buildList(input.lines, (line) =>
|
|
403
|
+
ValueObjects.build({
|
|
404
|
+
sku: () => new Sku(line.sku),
|
|
405
|
+
quantity: () => new Quantity(line.quantity),
|
|
406
|
+
}),
|
|
407
|
+
),
|
|
408
|
+
});
|
|
409
|
+
// command: { orderId: OrderId; email: Email; lines: { sku: Sku; quantity: Quantity }[] }
|
|
410
|
+
|
|
411
|
+
// with input = { orderId: 'x', email: 'ana', lines: [{ sku: 'TORCH-01', quantity: 2 }, { sku: '', quantity: 0 }] }:
|
|
412
|
+
// ValidationException, error.errors:
|
|
413
|
+
// [
|
|
414
|
+
// { field: 'orderId', code: 'DOM003', message: 'Validation Error: Expected a valid UUID, but received "x".' },
|
|
415
|
+
// { field: 'email', code: 'isEmail', message: 'Email: Value does not satisfy isEmail' },
|
|
416
|
+
// { field: 'lines.1.sku', code: 'DOM004', message: 'Validation Error: Sku is required, but received "".' },
|
|
417
|
+
// { field: 'lines.1.quantity', code: 'isInt', message: 'Quantity: From 1 to 20 units' },
|
|
418
|
+
// ]
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
`HttpErrorMapper` answers it with a 422 whose body carries the same `errors`, so a client can mark
|
|
422
|
+
each input. To report a problem of your own in the same shape, throw
|
|
423
|
+
`new ValidationException([{ field: 'deliveryDate', code: 'ORD003', message: '…' }])`; `code` is
|
|
424
|
+
optional and defaults to `'DOM001'`.
|
|
425
|
+
|
|
426
|
+
### Which layer validates what
|
|
427
|
+
|
|
428
|
+
The input that reaches a use case is not validated yet, and that is on purpose: every adapter
|
|
429
|
+
(HTTP, a queue, a CLI, a test) goes through the use case, so the rules live in one place.
|
|
430
|
+
|
|
431
|
+
| What | Where |
|
|
432
|
+
| --------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
433
|
+
| The shape of the request (it parses, a field is a string) | The input adapter (controller, consumer); no business rule |
|
|
434
|
+
| One value: required, convertible, its rules | The value object's constructor, through `ValueObjects.build` in `toCommand` |
|
|
435
|
+
| Rules over several values of one aggregate (invariants) | The aggregate's methods, with a `DomainException` |
|
|
436
|
+
| Rules over several aggregates | A domain service |
|
|
437
|
+
| Whether the caller may run the action | The use case (application) |
|
|
438
|
+
|
|
439
|
+
Do not repeat a value object's rule in the request schema: the adapter checks that the email is a
|
|
440
|
+
string, `Email` checks that it is an email. Invariants run after the fields are valid, because they
|
|
441
|
+
need valid value objects to be evaluated.
|
|
442
|
+
|
|
443
|
+
### Immutability
|
|
444
|
+
|
|
445
|
+
A value object never changes after construction (`_value` is `readonly`); to change it, build a new
|
|
446
|
+
one:
|
|
447
|
+
|
|
448
|
+
- `DateType` (and `CreatedAt`/`UpdatedAt`) holds a date whose setters throw:
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
new DateType(new Date('2024-01-31T10:00:00Z')).value!.setFullYear(2000);
|
|
452
|
+
// TypeError: Cannot call setFullYear: the date is immutable
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
- `JsonType` and `ArrayType` hold deeply frozen copies (`Object.isFrozen(vo.value)` is `true`).
|
|
456
|
+
- `ArrayType.withItem()` and `withoutItem()` return a **new** instance and leave the original
|
|
457
|
+
untouched, so a value object shared by two aggregates never changes under one of them.
|
|
458
|
+
- To touch an `UpdatedAt`, replace it: `this._updatedAt = UpdatedAt.now()`.
|
|
459
|
+
- The same helpers are exported for your own value objects: `Immutable.freeze(value)` deep-freezes
|
|
460
|
+
an object or array and returns it, and `Immutable.date(date)` returns a copy of a `Date` whose
|
|
461
|
+
setters throw.
|
|
462
|
+
|
|
463
|
+
### `UuidType` and `IdType`
|
|
464
|
+
|
|
465
|
+
```ts
|
|
466
|
+
import { IdType, UuidType } from '@archi-code/ddd/domain';
|
|
467
|
+
|
|
468
|
+
UuidType.random(); // random UUID v4
|
|
469
|
+
UuidType.fromValue('ana@example.com'); // '4badeee4-791f-51e2-a027-2c59bc6f8144' (v5, deterministic)
|
|
470
|
+
UuidType.fromValue('ana@example.com', '6ba7b811-9dad-11d1-80b4-00c04fd430c8'); // v5 with another namespace
|
|
471
|
+
|
|
472
|
+
new UuidType('abc');
|
|
473
|
+
// TypePrimitiveException: Validation Error: Expected a valid UUID, but received "abc".
|
|
474
|
+
|
|
475
|
+
const id = new IdType(IdType.random()); // required
|
|
476
|
+
const orderId = OrderId.generate(); // class OrderId extends IdType<'OrderId'> {}: a new OrderId with a random id
|
|
477
|
+
new IdType('9B2F6C1E-4D3A-4F8B-A1C2-3E4D5F6A7B8C').value; // '9b2f6c1e-4d3a-4f8b-a1c2-3e4d5f6a7b8c'
|
|
478
|
+
new IdType('00000000-0000-0000-0000-000000000000');
|
|
479
|
+
// TypePrimitiveException: Validation Error: Expected a valid Id, but received "00000000-0000-0000-0000-000000000000".
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
`fromValue(value, namespace?)` uses the DNS namespace by default
|
|
483
|
+
(`6ba7b810-9dad-11d1-80b4-00c04fd430c8`). Both types store the UUID in lowercase, so an id typed in
|
|
484
|
+
capitals equals the stored one. `generate()` builds a value object of the class you call it on
|
|
485
|
+
(`OrderId.generate()` is an `OrderId`).
|
|
486
|
+
|
|
487
|
+
`IdType<Brand>` takes the class name as a type-only brand, so the compiler tells ids of different
|
|
488
|
+
aggregates apart even though they have the same shape:
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
class OrderId extends IdType<'OrderId'> {}
|
|
492
|
+
class CustomerId extends IdType<'CustomerId'> {}
|
|
493
|
+
|
|
494
|
+
const orderId: OrderId = CustomerId.generate(); // compile error: a CustomerId is not an OrderId
|
|
495
|
+
const anyId: IdType = OrderId.generate(); // a plain IdType accepts any id
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
The brand does not exist at run time (`equals` already compares the class). A subclass without it
|
|
499
|
+
(`extends IdType {}`) compiles but is accepted wherever another unbranded id is expected.
|
|
500
|
+
|
|
501
|
+
### `CreatedAt` and `UpdatedAt`
|
|
502
|
+
|
|
503
|
+
Required dates:
|
|
504
|
+
|
|
505
|
+
```ts
|
|
506
|
+
import { CreatedAt, UpdatedAt } from '@archi-code/ddd/domain';
|
|
507
|
+
|
|
508
|
+
const createdAt = CreatedAt.now();
|
|
509
|
+
let updatedAt = UpdatedAt.now();
|
|
510
|
+
updatedAt = UpdatedAt.now(); // a new timestamp is a new value object
|
|
511
|
+
createdAt.value; // Date
|
|
512
|
+
|
|
513
|
+
CreatedAt.now(clock); // takes the instant from any { now(): Date }, such as a Clock port
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Pass a `Clock` (see the application layer) to `now(clock)` so use cases and tests get a known
|
|
517
|
+
time.
|
|
518
|
+
|
|
519
|
+
### `EnumType`
|
|
520
|
+
|
|
521
|
+
Abstract class: implement `getEnum()` returning the enum. It accepts exactly one of its options;
|
|
522
|
+
for a numeric enum it also accepts the option written as decimal text (`'1'` from a query string),
|
|
523
|
+
but not other spellings (`' 1'`, `'1e0'`) nor the key name.
|
|
524
|
+
|
|
525
|
+
```ts
|
|
526
|
+
import { EnumType } from '@archi-code/ddd/domain';
|
|
527
|
+
|
|
528
|
+
enum Status {
|
|
529
|
+
ACTIVE = 'active',
|
|
530
|
+
BLOCKED = 'blocked',
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
class StatusType extends EnumType<Status> {
|
|
534
|
+
protected getEnum(): typeof Status {
|
|
535
|
+
return Status;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
new StatusType(Status.ACTIVE).value; // 'active'
|
|
540
|
+
new StatusType('ACTIVE' as Status);
|
|
541
|
+
// TypePrimitiveException: Validation Error: Expected one of [active, blocked], but received "ACTIVE".
|
|
542
|
+
|
|
543
|
+
enum Level {
|
|
544
|
+
LOW = 1,
|
|
545
|
+
HIGH = 2,
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
class LevelType extends EnumType<Level> {
|
|
549
|
+
protected getEnum(): typeof Level {
|
|
550
|
+
return Level;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
new LevelType(Level.HIGH).value; // 2
|
|
555
|
+
new LevelType('1' as any).value; // 1
|
|
556
|
+
new LevelType('3' as any);
|
|
557
|
+
// TypePrimitiveException: Validation Error: Expected one of [1, 2], but received "3".
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
In `PrimitiveTypes`, an `EnumType<V>` field is typed as `V` (`Status`, `Level`), so
|
|
561
|
+
`fromPrimitives` passes it back without a cast: `new StatusType(primitives.status)`.
|
|
562
|
+
|
|
563
|
+
### `JsonType`
|
|
564
|
+
|
|
565
|
+
Holds a non-empty JSON object (frozen copy). `T` must extend `JsonTypeValue`
|
|
566
|
+
(`Record<string, any>`).
|
|
567
|
+
|
|
568
|
+
```ts
|
|
569
|
+
import { JsonType } from '@archi-code/ddd/domain';
|
|
570
|
+
import type { JsonTypeValue } from '@archi-code/ddd/domain';
|
|
571
|
+
|
|
572
|
+
interface Address extends JsonTypeValue {
|
|
573
|
+
city: string;
|
|
574
|
+
zip: string;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
class AddressType extends JsonType<Address> {}
|
|
578
|
+
|
|
579
|
+
const address = new AddressType({ city: 'Huelva', zip: '21001' });
|
|
580
|
+
address.value; // { city: 'Huelva', zip: '21001' }
|
|
581
|
+
address.toString(); // '{"city":"Huelva","zip":"21001"}'
|
|
582
|
+
address.equals(new AddressType({ zip: '21001', city: 'Huelva' })); // true (key order does not matter)
|
|
583
|
+
new AddressType('{"city":"Huelva","zip":"21001"}' as any).value; // { city: 'Huelva', zip: '21001' }
|
|
584
|
+
|
|
585
|
+
new JsonType({});
|
|
586
|
+
// TypePrimitiveException: Validation Error: Expected a valid Json, but received {}.
|
|
587
|
+
new JsonType([1] as any);
|
|
588
|
+
// TypePrimitiveException: Validation Error: Expected a valid Json, but received [1].
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
### `ArrayType`
|
|
592
|
+
|
|
593
|
+
Abstract class: implement `createItem(value)` returning the value object of each item. The array
|
|
594
|
+
holds the values already converted by each item; `items` rebuilds the value objects. Items are
|
|
595
|
+
compared by value (`hasItem`, `withoutItem`), also for dates and JSON items. `withItem` and
|
|
596
|
+
`withoutItem` return a new instance of the same class (built with `new YourClass(values)`, so keep the
|
|
597
|
+
single-value constructor).
|
|
598
|
+
|
|
599
|
+
```ts
|
|
600
|
+
import { Validations } from '@archi-code/validation';
|
|
601
|
+
import { ArrayType, Required, StringType } from '@archi-code/ddd/domain';
|
|
602
|
+
|
|
603
|
+
@Validations([{ validator: 'isLength', properties: { min: 2 } }])
|
|
604
|
+
class Tag extends Required(StringType) {}
|
|
605
|
+
|
|
606
|
+
class Tags extends ArrayType<Tag> {
|
|
607
|
+
protected createItem(value: unknown): Tag {
|
|
608
|
+
return new Tag(value as string);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const tags = new Tags(['ddd', 'ts']);
|
|
613
|
+
tags.value; // ['ddd', 'ts']
|
|
614
|
+
tags.items; // [Tag, Tag]
|
|
615
|
+
tags.hasItem('ts'); // true
|
|
616
|
+
const more = tags.withItem('node'); // Tags ['ddd', 'ts', 'node']
|
|
617
|
+
const fewer = more.withoutItem('ts'); // Tags ['ddd', 'node']
|
|
618
|
+
tags.value; // ['ddd', 'ts'] (unchanged)
|
|
619
|
+
fewer.toString(); // 'ddd, node'
|
|
620
|
+
const unique = tags.hasItem('ddd') ? tags : tags.withItem('ddd'); // add only if missing
|
|
621
|
+
|
|
622
|
+
new Tags('["a1","b2"]' as any).value; // ['a1', 'b2']
|
|
623
|
+
new Tags('abc' as any);
|
|
624
|
+
// TypePrimitiveException: Validation Error: Expected a valid Array, but received "abc".
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
An `ArrayType` builds every item and reports, at once, the rules of the list (`field: null`) and
|
|
628
|
+
the errors of each item with its position (`field: '1'`, from 0):
|
|
629
|
+
|
|
630
|
+
```ts
|
|
631
|
+
new Tags(['ok', 'x', '']);
|
|
632
|
+
// ValidationException, error.errors:
|
|
633
|
+
// [
|
|
634
|
+
// { field: '1', code: 'isLength', message: 'Tag: Value does not satisfy isLength' },
|
|
635
|
+
// { field: '2', code: 'DOM004', message: 'Validation Error: Tag is required, but received "".' },
|
|
636
|
+
// ]
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
### Custom value objects with `AbstractType`
|
|
640
|
+
|
|
641
|
+
For a new type, extend `AbstractType<T>` and implement `filter(value)`: it receives the input
|
|
642
|
+
already known to be non-empty, returns the converted value and throws `TypePrimitiveException` if it
|
|
643
|
+
is not valid. Empty input (`null`, `undefined`, blanks) and `Required(...)` are handled by the base
|
|
644
|
+
class.
|
|
645
|
+
|
|
646
|
+
```ts
|
|
647
|
+
import { AbstractType, Required } from '@archi-code/ddd/domain';
|
|
648
|
+
import { TypePrimitiveException } from '@archi-code/ddd/exceptions';
|
|
649
|
+
|
|
650
|
+
class Percentage extends AbstractType<number> {
|
|
651
|
+
protected filter(value: unknown): number {
|
|
652
|
+
const n = Number(value);
|
|
653
|
+
if (!Number.isFinite(n) || n < 0 || n > 100) {
|
|
654
|
+
throw new TypePrimitiveException('Percentage', value);
|
|
655
|
+
}
|
|
656
|
+
return n;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
new Percentage(40).value; // 40
|
|
661
|
+
new Percentage(140);
|
|
662
|
+
// TypePrimitiveException: Validation Error: Expected a valid Percentage, but received 140.
|
|
663
|
+
|
|
664
|
+
class Discount extends Required(Percentage) {}
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
To restrict an existing type, it is enough to extend it and declare `@Validations`, like `Email`.
|
|
668
|
+
|
|
669
|
+
### Composite value objects: `Composite({...})`
|
|
670
|
+
|
|
671
|
+
A concept made of several values that only make sense together (an amount and its currency, a
|
|
672
|
+
date range, an address, an order line) is one value object, not loose fields of an aggregate nor a
|
|
673
|
+
child entity [IDDD ch. 6]. `Composite(schema)` builds its base class from a schema that names the
|
|
674
|
+
class of each field:
|
|
675
|
+
|
|
676
|
+
| Field in the schema | Holds | Primitive |
|
|
677
|
+
| -------------------- | -------------------------- | -------------------- |
|
|
678
|
+
| A value object class | An instance of that class | Its value |
|
|
679
|
+
| A composite class | An instance of that class | Its primitives |
|
|
680
|
+
| `[ItemClass]` | A frozen list of instances | A list of primitives |
|
|
681
|
+
|
|
682
|
+
```ts
|
|
683
|
+
import { Validations } from '@archi-code/validation';
|
|
684
|
+
import { Composite, DateType, NumberType, Required, StringType } from '@archi-code/ddd/domain';
|
|
685
|
+
import { DomainException } from '@archi-code/ddd/exceptions';
|
|
686
|
+
|
|
687
|
+
@Validations([{ validator: 'isInt' }])
|
|
688
|
+
class Cents extends Required(NumberType) {}
|
|
689
|
+
|
|
690
|
+
@Validations([{ validator: 'isIn', properties: { values: ['EUR', 'USD'] } }])
|
|
691
|
+
class Currency extends Required(StringType) {}
|
|
692
|
+
|
|
693
|
+
class CurrencyMismatch extends DomainException {
|
|
694
|
+
constructor(expected: string, received: string) {
|
|
695
|
+
super(`Cannot add ${received} to ${expected}`, ['PAY001']);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
class Money extends Composite({ amount: Cents, currency: Currency }) {
|
|
700
|
+
add(other: Money): Money {
|
|
701
|
+
if (!this.currency.equals(other.currency)) {
|
|
702
|
+
throw new CurrencyMismatch(this.currency.value, other.currency.value);
|
|
703
|
+
}
|
|
704
|
+
return new Money({
|
|
705
|
+
amount: new Cents(this.amount.value + other.amount.value),
|
|
706
|
+
currency: this.currency,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const price = new Money({ amount: new Cents(1250), currency: new Currency('EUR') });
|
|
712
|
+
price.amount; // Cents (read-only)
|
|
713
|
+
price.add(price).toPrimitives(); // { amount: 2500, currency: 'EUR' }
|
|
714
|
+
price.add(new Money({ amount: new Cents(100), currency: new Currency('USD') }));
|
|
715
|
+
// CurrencyMismatch: Cannot add USD to EUR (code 'PAY001')
|
|
716
|
+
new Cents(12.5);
|
|
717
|
+
// ValidationException: Validation failed, Cents: Value does not satisfy isInt
|
|
718
|
+
price.equals(Money.fromPrimitives({ amount: 1250, currency: 'EUR' })); // true
|
|
719
|
+
JSON.stringify(price); // '{"amount":1250,"currency":"EUR"}'
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
- **The constructor** takes value objects only. A missing field throws `RequiredValueException`
|
|
723
|
+
(`Money.currency is required`) and an instance of another class `TypePrimitiveException`, even
|
|
724
|
+
when TypeScript accepts it because both classes have the same shape.
|
|
725
|
+
- **Rules over several fields** are `@Validations` `custom` rules on the composite class. They run
|
|
726
|
+
in the constructor, once every field is valid, and `fn` receives the built composite. A broken
|
|
727
|
+
rule throws `ValidationException` (`field: null`), like any value object:
|
|
728
|
+
|
|
729
|
+
```ts
|
|
730
|
+
class StartDate extends Required(DateType) {}
|
|
731
|
+
class EndDate extends Required(DateType) {}
|
|
732
|
+
|
|
733
|
+
@Validations([
|
|
734
|
+
{
|
|
735
|
+
custom: 'endsAfterItStarts',
|
|
736
|
+
fn: (range) => (range as Promotion).end.value >= (range as Promotion).start.value,
|
|
737
|
+
message: 'A promotion cannot end before it starts',
|
|
738
|
+
},
|
|
739
|
+
])
|
|
740
|
+
class Promotion extends Composite({ start: StartDate, end: EndDate }) {}
|
|
741
|
+
|
|
742
|
+
Promotion.fromPrimitives({ start: '2026-06-20', end: '2026-03-20' } as any);
|
|
743
|
+
// ValidationException: Validation failed, Promotion: A promotion cannot end before it starts
|
|
744
|
+
// error.errors: [{ field: null, code: 'endsAfterItStarts', message: 'Promotion: A promotion cannot end before it starts' }]
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
- **It never changes**: the instance and its lists are frozen and its fields are read-only, so
|
|
748
|
+
behavior returns a new instance. Do not add instance fields to the subclass, and write derived
|
|
749
|
+
values as methods (`money.isZero()`), never getters.
|
|
750
|
+
- **`X.fromPrimitives(data)`** rebuilds it from stored or received data, converting each primitive
|
|
751
|
+
like the value objects do, and reports every invalid field with its path, also nested ones.
|
|
752
|
+
`data` is a `CompositeInput`: each field takes what its value object's constructor takes (a date
|
|
753
|
+
field takes a `Date` or ISO text). It works inside `ValueObjects.build`, so a command reports
|
|
754
|
+
`price.amount`:
|
|
755
|
+
|
|
756
|
+
```ts
|
|
757
|
+
class Sku extends Required(StringType) {}
|
|
758
|
+
class Quantity extends Required(NumberType) {}
|
|
759
|
+
class OrderLine extends Composite({ sku: Sku, quantity: Quantity, unitPrice: Money }) {}
|
|
760
|
+
class Cart extends Composite({ lines: [OrderLine] }) {}
|
|
761
|
+
|
|
762
|
+
Cart.fromPrimitives({
|
|
763
|
+
lines: [{ sku: '', quantity: 'two', unitPrice: { amount: 1250, currency: 'GBP' } }],
|
|
764
|
+
} as any);
|
|
765
|
+
// ValidationException, error.errors:
|
|
766
|
+
// [
|
|
767
|
+
// { field: 'lines.0.sku', code: 'DOM004', message: 'Validation Error: Sku is required, but received "".' },
|
|
768
|
+
// { field: 'lines.0.quantity', code: 'DOM003', message: 'Validation Error: Expected a valid Number, but received "two".' },
|
|
769
|
+
// { field: 'lines.0.unitPrice.currency', code: 'isIn', message: 'Currency: Value does not satisfy isIn' },
|
|
770
|
+
// ]
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
- **`equals(other)`** compares the class and every field (dates by instant, lists in order):
|
|
774
|
+
a `Price` and a `Money` with the same fields are different. **`toPrimitives()`** gives plain data
|
|
775
|
+
for repositories (dates stay `Date`); `JSON.stringify` writes ISO dates.
|
|
776
|
+
- **In an aggregate**, a composite is a field like any value object:
|
|
777
|
+
`PrimitiveTypes<Order>` types it as its primitives, and an optional one is a `Money | null`
|
|
778
|
+
field. Write `this.price.toPrimitives()` in `toPrimitives()` and `Money.fromPrimitives(...)` in
|
|
779
|
+
`fromPrimitives`. Fields inside a composite are always present.
|
|
780
|
+
|
|
781
|
+
## Specifications
|
|
782
|
+
|
|
783
|
+
A business rule with a name ("eligible for express shipping", "customer in arrears") that is used in
|
|
784
|
+
several places or combined with others is a `Specification<T>` [IDDD ch. 5]: it validates,
|
|
785
|
+
selects or builds, and combines with `and`, `or` and `not` (each returns a `Specification<T>`, so
|
|
786
|
+
they keep combining; `and`/`or` stop at the first answer that decides). `and` and `or` accept any
|
|
787
|
+
`SatisfiedBy<T>` (an object with `isSatisfiedBy(candidate): boolean`), not only subclasses, as the
|
|
788
|
+
books' `Specification` interface does [IDDD ch. 5].
|
|
789
|
+
|
|
790
|
+
```ts
|
|
791
|
+
import { Specification } from '@archi-code/ddd/domain';
|
|
792
|
+
|
|
793
|
+
class ShipsToThePeninsula extends Specification<Order> {
|
|
794
|
+
isSatisfiedBy(order: Order): boolean {
|
|
795
|
+
return order.destination.zone.value === 'PENINSULA';
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
class InCentralStock extends Specification<Order> {
|
|
800
|
+
constructor(private readonly stock: StockSnapshot) {
|
|
801
|
+
super();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
isSatisfiedBy(order: Order): boolean {
|
|
805
|
+
return order.lines.every((line) => this.stock.covers(line.sku, line.quantity));
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const expressShipping = new ShipsToThePeninsula().and(new InCentralStock(stock));
|
|
810
|
+
expressShipping.isSatisfiedBy(order); // validate
|
|
811
|
+
orders.filter((order) => expressShipping.isSatisfiedBy(order)); // select
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
A specification receives the data it needs already loaded (a snapshot of the stock), never a
|
|
815
|
+
repository. It does not protect the invariants of an aggregate: those stay in the aggregate.
|
|
816
|
+
|
|
817
|
+
## Aggregates and domain events
|
|
818
|
+
|
|
819
|
+
- `AggregateRoot<Id = IdType, Event = EventBase>`: abstract class for aggregates. `new (id)` stores
|
|
820
|
+
`readonly id`; `equals(other)` is true for the same class and an equal id, whatever the other
|
|
821
|
+
attributes are. `Id` may be any required value object (`EntityId`: `IdType`, a `Required(StringType)` code…); an optional value object cannot be an id, and an id that holds no value throws `RequiredValueException`, so two new entities are never taken for the same one [IDDD ch. 5]; `record(event)` is `protected` and deep-freezes the event (a fact never changes [IDDD ch. 8]); `pullDomainEvents()` returns the events typed as `Event[]`, in order, and empties
|
|
822
|
+
the list. Pending events are not an enumerable property: they never show up in
|
|
823
|
+
`JSON.stringify(aggregate)`, `{ ...aggregate }` or `Object.keys`.
|
|
824
|
+
- Every aggregate has a `version` for optimistic concurrency (see
|
|
825
|
+
[Optimistic concurrency](#optimistic-concurrency)): `0` for a new one, the stored version for a
|
|
826
|
+
loaded one, one more after each successful save. The aggregate does nothing with it: it keeps
|
|
827
|
+
calling `super(id)`, and the version is neither a field, nor in its primitives, nor in its JSON.
|
|
828
|
+
Only repositories touch it, with `restoreVersion(version)` when they load and `markPersisted()`
|
|
829
|
+
after they save.
|
|
830
|
+
- Parts of an aggregate are composite value objects when nobody looks them up by an id of their
|
|
831
|
+
own (the lines of an order). A part that does need its own identity inside the aggregate (a seat
|
|
832
|
+
of a room) is a child entity: it extends `Entity<Id>`, which only stores `readonly id` and
|
|
833
|
+
compares by class and id; it holds value objects and may have business methods with the rules of
|
|
834
|
+
that part, but no setters, and only the root calls them [IDDD ch. 5 and 10]. Its identity is
|
|
835
|
+
local: unique only inside its aggregate and never used to look it up from outside [IDDD ch. 10],
|
|
836
|
+
so a small value object is enough (`class SeatNumber extends Required(NumberType) {}`).
|
|
837
|
+
`AggregateRoot` extends `Entity` too, and only the root is loaded and saved by repositories.
|
|
838
|
+
|
|
839
|
+
```ts
|
|
840
|
+
import {
|
|
841
|
+
AggregateRoot,
|
|
842
|
+
BooleanType,
|
|
843
|
+
Entity,
|
|
844
|
+
IdType,
|
|
845
|
+
NumberType,
|
|
846
|
+
Required,
|
|
847
|
+
StringType,
|
|
848
|
+
} from '@archi-code/ddd/domain';
|
|
849
|
+
import { DomainException } from '@archi-code/ddd/exceptions';
|
|
850
|
+
|
|
851
|
+
class SeatNumber extends Required(NumberType) {}
|
|
852
|
+
class SeatLabel extends Required(StringType) {}
|
|
853
|
+
class Sold extends Required(BooleanType) {}
|
|
854
|
+
|
|
855
|
+
class SeatAlreadySold extends DomainException {
|
|
856
|
+
constructor(seat: number) {
|
|
857
|
+
super(`Seat ${seat} is already sold and keeps its label`, ['ROOM001']);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
class Seat extends Entity<SeatNumber> {
|
|
862
|
+
constructor(
|
|
863
|
+
id: SeatNumber,
|
|
864
|
+
private _label: SeatLabel,
|
|
865
|
+
readonly sold: Sold,
|
|
866
|
+
) {
|
|
867
|
+
super(id);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
get label(): SeatLabel {
|
|
871
|
+
return this._label;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
relabel(label: SeatLabel): void {
|
|
875
|
+
if (this.sold.value) {
|
|
876
|
+
throw new SeatAlreadySold(this.id.value);
|
|
877
|
+
}
|
|
878
|
+
this._label = label;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
class Room extends AggregateRoot<IdType> {
|
|
883
|
+
private _seats: Seat[] = [];
|
|
884
|
+
|
|
885
|
+
get seats(): Seat[] {
|
|
886
|
+
return [...this._seats];
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
relabelSeat(id: SeatNumber, label: SeatLabel): void {
|
|
890
|
+
this._seats.find((seat) => seat.id.equals(id))?.relabel(label);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
new Seat(new SeatNumber(7), new SeatLabel('A1'), new Sold(false)).equals(
|
|
895
|
+
new Seat(new SeatNumber(7), new SeatLabel('B7'), new Sold(true)),
|
|
896
|
+
); // true
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
- `EventBase`: abstract class for events; declare
|
|
900
|
+
`static readonly eventName = '<ClassName>'` (`'OrderPlaced'` [IDDD ch. 8]; the
|
|
901
|
+
`'<context>.<module>.<Event>'` names are for the contracts other contexts read) (listeners and registries refer to the event by its class, without an instance; `eventName()`
|
|
902
|
+
returns it, and a subclass inherits it). Creating an event whose class has no non-blank
|
|
903
|
+
`eventName` throws `InternalErrorException`. `new (aggregateId, metadata?)` sets `aggregateId`, a new `eventId` (UUID v4) and an immutable `occurredOn`. Pass
|
|
904
|
+
`{ clock }` (any `{ now(): Date }`, such as the `Clock` port) to take the moment from it; without
|
|
905
|
+
a clock it is the system time. When you rebuild an event a channel delivered, pass
|
|
906
|
+
`{ eventId, occurredOn }` (`Date` or ISO string with zone) to keep them; a stored `occurredOn`
|
|
907
|
+
wins over a clock. The clock is not kept in the event nor in its JSON. `toJSON()` gives
|
|
908
|
+
`{ eventName, eventVersion, eventId, aggregateId, occurredOn (ISO), ...payload }`.
|
|
909
|
+
- The shape of an event's data has a version: `static readonly eventVersion = 2` when it changes
|
|
910
|
+
in a way old readers cannot follow (renaming or removing a field, changing its meaning; adding an
|
|
911
|
+
optional field needs no new version). It is 1 when not declared, `event.eventVersion()` returns
|
|
912
|
+
it, and a version below 1 or not whole throws `InternalErrorException`. Stored events keep the
|
|
913
|
+
version they were written with, so the `EventRegistry` factory can read old shapes
|
|
914
|
+
(`json.eventVersion === 1 ? … : …`).
|
|
915
|
+
- The data of an event are primitives taken from value objects (`id.value`, money as
|
|
916
|
+
`{ amount, currency }`), never value objects nor the whole state of the aggregate: the event is
|
|
917
|
+
serialized as it is, and its readers must not depend on the model.
|
|
918
|
+
- `version` counts saves, not events: it grows by 1 on each save, however many events the change
|
|
919
|
+
recorded. Event Sourcing is not provided: an event-sourced aggregate manages the version of its
|
|
920
|
+
stream itself.
|
|
921
|
+
|
|
922
|
+
```ts
|
|
923
|
+
import { AggregateRoot, EventBase, IdType, Required, StringType } from '@archi-code/ddd/domain';
|
|
924
|
+
import type { EventMetadata } from '@archi-code/ddd/domain';
|
|
925
|
+
import { FixedClock } from '@archi-code/ddd/infrastructure';
|
|
926
|
+
|
|
927
|
+
class OrderId extends IdType<'OrderId'> {}
|
|
928
|
+
class CustomerName extends Required(StringType) {}
|
|
929
|
+
|
|
930
|
+
class OrderPlaced extends EventBase {
|
|
931
|
+
static readonly eventName = 'OrderPlaced';
|
|
932
|
+
|
|
933
|
+
constructor(
|
|
934
|
+
orderId: string,
|
|
935
|
+
readonly customer: string,
|
|
936
|
+
metadata?: EventMetadata,
|
|
937
|
+
) {
|
|
938
|
+
super(orderId, metadata);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
class Order extends AggregateRoot<OrderId, OrderPlaced> {
|
|
943
|
+
static place(id: OrderId, customer: CustomerName, clock: { now(): Date }): Order {
|
|
944
|
+
const order = new Order(id);
|
|
945
|
+
order.record(new OrderPlaced(id.value, customer.value, { clock }));
|
|
946
|
+
return order;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const order = Order.place(
|
|
951
|
+
new OrderId('9b2f6c1e-4d3a-4f8b-a1c2-3e4d5f6a7b8c'),
|
|
952
|
+
new CustomerName('Ana'),
|
|
953
|
+
new FixedClock('2026-10-17T10:00:00Z'),
|
|
954
|
+
);
|
|
955
|
+
const [placed] = order.pullDomainEvents(); // OrderPlaced[]
|
|
956
|
+
order.pullDomainEvents(); // []
|
|
957
|
+
JSON.stringify(placed);
|
|
958
|
+
// {"eventName":"OrderPlaced","eventVersion":1,"eventId":"…","aggregateId":"9b2f6c1e-…","occurredOn":"2026-10-17T10:00:00.000Z","customer":"Ana"}
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
### Primitive types
|
|
962
|
+
|
|
963
|
+
Types to go from an aggregate (or any class with value objects) to its plain values:
|
|
964
|
+
|
|
965
|
+
| Type | Result |
|
|
966
|
+
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
967
|
+
| `PrimitiveType<T>` | Value object → its value (`T['value']`); `EnumType<V>` → the enum option type `V`; arrays item by item; `null` → `null`; a child entity (`extends Entity`) or a composite → its `PrimitiveTypes`; raw `string`/`number`/`boolean`/`Date` and anything else → `never` |
|
|
968
|
+
| `PrimitiveTypes<T>` | Object with each public non-method property of `T` as `PrimitiveType`, without the leading `_` of the name |
|
|
969
|
+
| `DataTypes<T>` | `Partial<PrimitiveTypes<T>>` |
|
|
970
|
+
| `Properties<T>` | The public properties of `T` that are not methods |
|
|
971
|
+
| `PropertiesData<T>` | `Required<Properties<T>>` |
|
|
972
|
+
| `MutablePropertiesData<T>` | `PropertiesData<T>` without `readonly` |
|
|
973
|
+
| `Methods<T>` | Union of the names of the methods of `T` |
|
|
974
|
+
| `MethodsAndProperties<T>` | `{ [key in keyof T]: T[key] }` |
|
|
975
|
+
| `RequiredType<C>` | Type of the class returned by `Required(C)` |
|
|
976
|
+
| `JsonTypeValue` | `Record<string, any>` |
|
|
977
|
+
|
|
978
|
+
`PrimitiveTypes` only sees **public** members (`keyof`): `private` or `protected` properties do not
|
|
979
|
+
appear. Expose the value objects with public `readonly` properties or with getters (see the full
|
|
980
|
+
example), and write derived values as methods: a getter such as `get total()` would become a key of
|
|
981
|
+
the primitives. For an aggregate it includes `id`, and child entities become their own primitives.
|
|
982
|
+
An enum field keeps its enum type, so `fromPrimitives` rebuilds it without a cast:
|
|
983
|
+
`new OrderStatusType(primitives.status)` (`primitives.status` is `OrderStatus`).
|
|
984
|
+
|
|
985
|
+
```ts
|
|
986
|
+
import type { DataTypes, PrimitiveTypes } from '@archi-code/ddd/domain';
|
|
987
|
+
|
|
988
|
+
class Person {
|
|
989
|
+
constructor(
|
|
990
|
+
readonly _name: Name,
|
|
991
|
+
readonly nickname: Nickname,
|
|
992
|
+
) {}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
type PersonPrimitives = PrimitiveTypes<Person>; // { name: string; nickname: string | null }
|
|
996
|
+
type PersonData = DataTypes<Person>; // { name?: string; nickname?: string | null }
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
## Repository contracts
|
|
1000
|
+
|
|
1001
|
+
The domain declares what it needs to load, save and remove aggregates; infrastructure implements
|
|
1002
|
+
it (types only, nothing to instantiate). The repository is split by role, following the interface
|
|
1003
|
+
segregation principle:
|
|
1004
|
+
|
|
1005
|
+
```ts
|
|
1006
|
+
interface AggregateFinder<Aggregate extends AggregateRoot<any, any>> {
|
|
1007
|
+
findById(id: Aggregate['id']): Promise<Aggregate | null>;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
interface AggregateSaver<Aggregate extends AggregateRoot<any, any>> {
|
|
1011
|
+
save(aggregate: Aggregate): Promise<void>;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
interface AggregateRemover<Aggregate extends AggregateRoot<any, any>> {
|
|
1015
|
+
remove(aggregate: Aggregate): Promise<void>;
|
|
1016
|
+
}
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
`remove` receives the loaded aggregate, not its id: removing is a change like any other, so the
|
|
1020
|
+
repository removes it only where the stored version still equals `aggregate.version`, and throws
|
|
1021
|
+
`ConcurrencyException` when someone changed or removed it after it was loaded [IDDD ch. 10]. A
|
|
1022
|
+
use case loads it with `findById`, decides, and removes it.
|
|
1023
|
+
|
|
1024
|
+
A use case asks only for the roles it uses: registering needs `AggregateSaver<User>`, renaming
|
|
1025
|
+
needs `AggregateFinder<User> & AggregateSaver<User>`, closing an account may need
|
|
1026
|
+
`AggregateFinder<User> & AggregateRemover<User>`. Its dependencies then say what it can do, and a
|
|
1027
|
+
test double implements one method. There is no all-in-one `Repository` contract: the adapter
|
|
1028
|
+
implements the roles it supports
|
|
1029
|
+
(`class MongoUserRepository implements AggregateFinder<User>, AggregateSaver<User>, AggregateRemover<User>`)
|
|
1030
|
+
and the same instance is passed to every use case.
|
|
1031
|
+
|
|
1032
|
+
Reading for a screen or a list does not go through aggregates nor repositories. A read query is an
|
|
1033
|
+
interface of the application, implemented in infrastructure, that returns DTOs or ids straight
|
|
1034
|
+
from the database (filter and paginate it with
|
|
1035
|
+
[`@archi-code/criteria`](https://www.npmjs.com/package/@archi-code/criteria)):
|
|
1036
|
+
|
|
1037
|
+
```ts
|
|
1038
|
+
interface OrderSummary {
|
|
1039
|
+
id: string;
|
|
1040
|
+
customer: string;
|
|
1041
|
+
totalCents: number;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
interface OrderSummaries {
|
|
1045
|
+
ofCustomer(customerId: string, page: { limit: number; offset: number }): Promise<OrderSummary[]>;
|
|
1046
|
+
}
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
### Optimistic concurrency
|
|
1050
|
+
|
|
1051
|
+
Two users load the same order, both change it and both save: without a check, the second save
|
|
1052
|
+
silently erases the first. `AggregateRoot` carries the version that prevents it, and every
|
|
1053
|
+
repository follows the same contract:
|
|
1054
|
+
|
|
1055
|
+
| Moment | The repository |
|
|
1056
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
1057
|
+
| Load | Rebuilds with `Order.fromPrimitives(row).restoreVersion(storedVersion)`: `aggregate.version` is what it read |
|
|
1058
|
+
| Save a new aggregate | `aggregate.version` is `0`: inserts with version `1`; if the id already exists, it is a conflict |
|
|
1059
|
+
| Save an existing aggregate | Writes only where the stored version still equals `aggregate.version`, and stores `aggregate.version + 1` |
|
|
1060
|
+
| Nothing written (stale data) | Throws `ConcurrencyException('Order', id, aggregate.version)` (HTTP 409) |
|
|
1061
|
+
| Written | Calls `aggregate.markPersisted()`, so the same object can be changed and saved again |
|
|
1062
|
+
| Remove | Deletes only where the stored version still equals `aggregate.version`; otherwise `ConcurrencyException` |
|
|
1063
|
+
|
|
1064
|
+
The version lives next to the primitives (a `version` column or field), never inside them. After a
|
|
1065
|
+
`ConcurrencyException`, either tell the user that someone else changed it, or run the command
|
|
1066
|
+
again, which loads the aggregate again. The use case may retry by itself when its command is safe
|
|
1067
|
+
to repeat against the new state [IDDD ch. 10; IDDD appendix A]: each attempt loads the aggregate
|
|
1068
|
+
again and saves it again, written in `handle` (there is no helper), and it never calls another use
|
|
1069
|
+
case. A use case run by a listener does not retry by itself: it lets the conflict propagate and the
|
|
1070
|
+
channel's redelivery tries again:
|
|
1071
|
+
|
|
1072
|
+
```ts
|
|
1073
|
+
import { UseCase } from '@archi-code/ddd/application';
|
|
1074
|
+
import { AggregateNotFoundException, ConcurrencyException } from '@archi-code/ddd/exceptions';
|
|
1075
|
+
|
|
1076
|
+
interface AddOrderLineInput {
|
|
1077
|
+
orderId: string;
|
|
1078
|
+
sku: string;
|
|
1079
|
+
quantity: number;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
interface AddOrderLineCommand {
|
|
1083
|
+
orderId: OrderId;
|
|
1084
|
+
sku: Sku;
|
|
1085
|
+
quantity: Quantity;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
class AddOrderLine extends UseCase<AddOrderLineInput, AddOrderLineCommand, void> {
|
|
1089
|
+
// constructor (orders, events, clock) as in "Persistence belongs to the repository";
|
|
1090
|
+
// toCommand builds orderId, sku and quantity with ValueObjects.build;
|
|
1091
|
+
// Order, OrderLine, Sku and Quantity as in "Persisting aggregates"
|
|
1092
|
+
|
|
1093
|
+
protected async handle(command: AddOrderLineCommand): Promise<void> {
|
|
1094
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
1095
|
+
try {
|
|
1096
|
+
const order = await this.orders.findById(command.orderId);
|
|
1097
|
+
if (order === null) {
|
|
1098
|
+
throw new AggregateNotFoundException('Order', command.orderId.value);
|
|
1099
|
+
}
|
|
1100
|
+
order.addLine(new OrderLine({ sku: command.sku, quantity: command.quantity }));
|
|
1101
|
+
await this.orders.save(order);
|
|
1102
|
+
return await this.events.publish(order.pullDomainEvents());
|
|
1103
|
+
} catch (error) {
|
|
1104
|
+
if (!(error instanceof ConcurrencyException) || attempt === 3) {
|
|
1105
|
+
throw error;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
```
|
|
1112
|
+
|
|
1113
|
+
A command that is not safe to repeat (it would charge twice, or the user decided on data that
|
|
1114
|
+
changed) does not retry: the `ConcurrencyException` reaches the entry point (HTTP 409).
|
|
1115
|
+
|
|
1116
|
+
```ts
|
|
1117
|
+
import type { AggregateFinder, AggregateSaver } from '@archi-code/ddd/domain';
|
|
1118
|
+
import { ConcurrencyException, InternalErrorException } from '@archi-code/ddd/exceptions';
|
|
1119
|
+
|
|
1120
|
+
class MongoOrderRepository implements AggregateFinder<Order>, AggregateSaver<Order> {
|
|
1121
|
+
constructor(private readonly collection: Collection<OrderDocument>) {}
|
|
1122
|
+
|
|
1123
|
+
async findById(id: OrderId): Promise<Order | null> {
|
|
1124
|
+
const document = await this.collection.findOne({ _id: id.value });
|
|
1125
|
+
if (document === null) {
|
|
1126
|
+
return null;
|
|
1127
|
+
}
|
|
1128
|
+
const { _id, version, ...primitives } = document;
|
|
1129
|
+
return Order.fromPrimitives(primitives).restoreVersion(version);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
async save(order: Order): Promise<void> {
|
|
1133
|
+
const data = { ...order.toPrimitives(), version: order.version + 1 };
|
|
1134
|
+
const written =
|
|
1135
|
+
order.version === 0
|
|
1136
|
+
? await this.insert(data)
|
|
1137
|
+
: (await this.collection.replaceOne({ _id: data.id, version: order.version }, data))
|
|
1138
|
+
.matchedCount === 1;
|
|
1139
|
+
if (!written) {
|
|
1140
|
+
throw new ConcurrencyException('Order', order.id.value, order.version);
|
|
1141
|
+
}
|
|
1142
|
+
order.markPersisted();
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
private async insert(data: OrderDocument): Promise<boolean> {
|
|
1146
|
+
try {
|
|
1147
|
+
await this.collection.insertOne({ _id: data.id, ...data });
|
|
1148
|
+
return true;
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
if (isDuplicateKey(error)) {
|
|
1151
|
+
return false;
|
|
1152
|
+
}
|
|
1153
|
+
throw new InternalErrorException('saving order', { cause: error });
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
In SQL the same check is `UPDATE orders SET …, version = version + 1 WHERE id = $1 AND version = $2`
|
|
1160
|
+
and zero updated rows means a conflict.
|
|
1161
|
+
|
|
1162
|
+
### Repository contract tests
|
|
1163
|
+
|
|
1164
|
+
Write the contract once, in `test/support/`, and run it against your in-memory repository (see
|
|
1165
|
+
[Writing a repository](#writing-a-repository)) and against your real adapter, so the double the use
|
|
1166
|
+
case tests rely on behaves like production (`Order` is
|
|
1167
|
+
the one of [Persisting aggregates](#persisting-aggregates)):
|
|
1168
|
+
|
|
1169
|
+
```ts
|
|
1170
|
+
import { describe, expect, it } from 'vitest';
|
|
1171
|
+
import type { AggregateFinder, AggregateSaver } from '@archi-code/ddd/domain';
|
|
1172
|
+
import { ConcurrencyException } from '@archi-code/ddd/exceptions';
|
|
1173
|
+
import { FixedClock } from '@archi-code/ddd/infrastructure';
|
|
1174
|
+
|
|
1175
|
+
const clock = new FixedClock('2026-10-17T10:00:00Z');
|
|
1176
|
+
const anOrder = () => Order.place(OrderId.generate(), new CustomerName('Ana'), clock);
|
|
1177
|
+
|
|
1178
|
+
export function orderRepositoryContract(
|
|
1179
|
+
make: () => AggregateFinder<Order> & AggregateSaver<Order>,
|
|
1180
|
+
): void {
|
|
1181
|
+
it('finds what it saved', async () => {
|
|
1182
|
+
const orders = make();
|
|
1183
|
+
const order = anOrder();
|
|
1184
|
+
await orders.save(order);
|
|
1185
|
+
expect((await orders.findById(order.id))?.toPrimitives()).toStrictEqual(order.toPrimitives());
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
it('saves the same object twice', async () => {
|
|
1189
|
+
const orders = make();
|
|
1190
|
+
const order = anOrder();
|
|
1191
|
+
await orders.save(order);
|
|
1192
|
+
order.ship(clock);
|
|
1193
|
+
await orders.save(order);
|
|
1194
|
+
expect((await orders.findById(order.id))?.version).toBe(2);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
it('refuses a stale save', async () => {
|
|
1198
|
+
const orders = make();
|
|
1199
|
+
const order = anOrder();
|
|
1200
|
+
await orders.save(order);
|
|
1201
|
+
const first = (await orders.findById(order.id))!;
|
|
1202
|
+
const second = (await orders.findById(order.id))!;
|
|
1203
|
+
first.ship(clock);
|
|
1204
|
+
await orders.save(first);
|
|
1205
|
+
second.ship(clock);
|
|
1206
|
+
await expect(orders.save(second)).rejects.toBeInstanceOf(ConcurrencyException);
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
it('refuses a new aggregate whose id is taken', async () => {
|
|
1210
|
+
const orders = make();
|
|
1211
|
+
const order = anOrder();
|
|
1212
|
+
await orders.save(order);
|
|
1213
|
+
const copy = Order.place(order.id, new CustomerName('Luis'), clock);
|
|
1214
|
+
await expect(orders.save(copy)).rejects.toBeInstanceOf(ConcurrencyException);
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
describe('InMemoryOrderRepository', () => {
|
|
1219
|
+
orderRepositoryContract(() => new InMemoryOrderRepository());
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
describe('MongoOrderRepository', () => {
|
|
1223
|
+
orderRepositoryContract(() => new MongoOrderRepository(collection));
|
|
1224
|
+
});
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
# Application layer: `@archi-code/ddd/application`
|
|
1228
|
+
|
|
1229
|
+
## `UseCase<Input, Command, Output>`
|
|
1230
|
+
|
|
1231
|
+
Two rules shape every use case:
|
|
1232
|
+
|
|
1233
|
+
- **It does one thing.** One business action per class and one `handle`: it changes one aggregate
|
|
1234
|
+
and saves it once [Destilado ch. 5; IDDD ch. 10], except the cases IDDD ch. 10 accepts (such as
|
|
1235
|
+
creating several aggregates in one batch), written down as an explicit decision. A flow with several actions is several use cases, chained by domain events or
|
|
1236
|
+
called in order by whoever orchestrates them, never one use case whose input switches between
|
|
1237
|
+
behaviors.
|
|
1238
|
+
- **It has a business name.** A verb and a noun a domain expert would say: `PlaceOrder`,
|
|
1239
|
+
`ShipOrder`, `CancelSubscription`, `ApproveRefund`. Not `OrderService`, `UpdateOrder`,
|
|
1240
|
+
`SaveUser` or `ProcessData`.
|
|
1241
|
+
|
|
1242
|
+
A use case has two steps, and `execute(input)` (which always returns a promise) runs them in
|
|
1243
|
+
order:
|
|
1244
|
+
|
|
1245
|
+
1. `protected toCommand(input: Input): Command` builds the value objects the action needs with
|
|
1246
|
+
`ValueObjects.build({ orderId: () => new OrderId(input.orderId), email: () => new Email(input.email) })`
|
|
1247
|
+
(one value object alone can be built with `new`). If any field is invalid it throws one
|
|
1248
|
+
`ValidationException` listing every field, and `handle` never runs.
|
|
1249
|
+
2. `protected handle(command: Command)` (sync or async) coordinates the action with value objects
|
|
1250
|
+
only: load the aggregate, call its business method, save it, publish
|
|
1251
|
+
`aggregate.pullDomainEvents()`. It never opens a transaction: each write is made atomic by the
|
|
1252
|
+
repository (see [Persistence belongs to the repository](#persistence-belongs-to-the-repository)).
|
|
1253
|
+
|
|
1254
|
+
Errors of both steps are treated the same way:
|
|
1255
|
+
|
|
1256
|
+
- Exceptions of the package (`DomainException`, `ValidationException`, `ApplicationException`,
|
|
1257
|
+
`AggregateNotFoundException`…) pass through as they are, so the caller can map them.
|
|
1258
|
+
- Anything else (a driver error, a `TypeError`, a thrown string) is wrapped in
|
|
1259
|
+
`InternalErrorException` with the original as `cause` and the use case name in the message:
|
|
1260
|
+
`Failed, internal error: PlaceOrder: connection reset`.
|
|
1261
|
+
|
|
1262
|
+
`UseCase` does nothing else: loading, checking, saving and publishing are written in `handle`,
|
|
1263
|
+
so every step of the use case is visible in one place:
|
|
1264
|
+
|
|
1265
|
+
```ts
|
|
1266
|
+
class ShipOrder extends UseCase<{ orderId: string }, OrderId, void> {
|
|
1267
|
+
constructor(
|
|
1268
|
+
private readonly orders: AggregateFinder<Order> & AggregateSaver<Order>,
|
|
1269
|
+
private readonly bus: EventBus<OrderEvent>,
|
|
1270
|
+
private readonly clock: Clock,
|
|
1271
|
+
) {
|
|
1272
|
+
super();
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
protected toCommand(input: { orderId: string }): OrderId {
|
|
1276
|
+
return new OrderId(input.orderId);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
protected async handle(orderId: OrderId): Promise<void> {
|
|
1280
|
+
const order = await this.orders.findById(orderId);
|
|
1281
|
+
if (order === null) {
|
|
1282
|
+
throw new AggregateNotFoundException('Order', orderId.value);
|
|
1283
|
+
}
|
|
1284
|
+
order.ship(this.clock);
|
|
1285
|
+
await this.orders.save(order);
|
|
1286
|
+
await this.bus.publish(order.pullDomainEvents());
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
```
|
|
1290
|
+
|
|
1291
|
+
Save before publishing, and publish what `pullDomainEvents()` returns, so a failed save publishes
|
|
1292
|
+
nothing and events are never sent twice. A command with several values is a small interface of
|
|
1293
|
+
value objects (`interface PlaceOrderCommand { orderId: OrderId; customer: CustomerName }`); a
|
|
1294
|
+
use case never imports another use case.
|
|
1295
|
+
|
|
1296
|
+
### Use cases run by a listener
|
|
1297
|
+
|
|
1298
|
+
A use case that a listener runs registers a fact that already happened elsewhere, so it does not
|
|
1299
|
+
refuse it: when the aggregate is missing it returns instead of throwing
|
|
1300
|
+
`AggregateNotFoundException` (a not-found would block the channel's redelivery for ever), and the
|
|
1301
|
+
aggregate method is idempotent. Only technical errors propagate, so the delivery is retried:
|
|
1302
|
+
|
|
1303
|
+
```ts
|
|
1304
|
+
protected async handle(command: RegisterStockReservationCommand): Promise<void> {
|
|
1305
|
+
const order = await this.orders.findById(command.orderId);
|
|
1306
|
+
if (order === null) {
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
order.registerStockReservation(command.reservationId, this.clock);
|
|
1310
|
+
await this.orders.save(order);
|
|
1311
|
+
await this.bus.publish(order.pullDomainEvents());
|
|
1312
|
+
}
|
|
1313
|
+
```
|
|
1314
|
+
|
|
1315
|
+
### Authorization
|
|
1316
|
+
|
|
1317
|
+
Who may run the action is checked by the application. The adapter takes the requester's id from
|
|
1318
|
+
the token (never from the body) and puts it in the input; `toCommand` turns it into a value object;
|
|
1319
|
+
an application policy throws `NotAllowedException` (HTTP 403) when the requester may not act.
|
|
1320
|
+
Ownership rules that are business rules ("only the customer who placed an order can cancel it",
|
|
1321
|
+
"only pending orders can be cancelled") stay in the aggregate, with a `DomainException`:
|
|
1322
|
+
|
|
1323
|
+
```ts
|
|
1324
|
+
protected async handle(command: RefundPaymentCommand): Promise<void> {
|
|
1325
|
+
if (!(await this.permissions.canRefund(command.requesterId))) {
|
|
1326
|
+
throw new NotAllowedException(`User ${command.requesterId.value} may not refund payments`);
|
|
1327
|
+
}
|
|
1328
|
+
// load, change, save, publish
|
|
1329
|
+
}
|
|
1330
|
+
```
|
|
1331
|
+
|
|
1332
|
+
## Events: the contract in `application/event`
|
|
1333
|
+
|
|
1334
|
+
The application layer holds only the contract to publish and to listen, in one folder,
|
|
1335
|
+
`application/event`: `EventBus` (the port a use case publishes through) and `EventListener` +
|
|
1336
|
+
`@ListensTo` (what the application does when an event happens). Everything that moves events (the
|
|
1337
|
+
bus, the registry that rebuilds them and the channel port) is infrastructure, used only by the
|
|
1338
|
+
wiring (the composition root). Changing the messaging technology (in memory today, RabbitMQ, a
|
|
1339
|
+
database table or Kafka tomorrow) means **changing only the channel**: use cases, listeners and
|
|
1340
|
+
wiring stay as they are.
|
|
1341
|
+
|
|
1342
|
+
```
|
|
1343
|
+
use case ──► EventBus (port, @archi-code/ddd/application)
|
|
1344
|
+
▲ implements
|
|
1345
|
+
wiring ──► DomainEventBus (toJSON, @ListensTo, rebuild, listener.handle) (@archi-code/ddd/infrastructure)
|
|
1346
|
+
└──► EventChannel (port) ◄── InMemoryEventChannel (@archi-code/ddd/infrastructure)
|
|
1347
|
+
send / receive ◄── your RabbitMQ, database or Kafka channel
|
|
1348
|
+
```
|
|
1349
|
+
|
|
1350
|
+
| Piece | Module | Kind | Role |
|
|
1351
|
+
| ------------------------------------------------------- | -------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
1352
|
+
| `EventListener`, `@ListensTo` | application | class, decorator | What the application does when an event happens |
|
|
1353
|
+
| `EventBus` | application | port (type) | `publish(events)`: the only thing a use case knows |
|
|
1354
|
+
| `DomainEventBus(channel, registry)`, `Subscription` | infrastructure | class, type | Implements `EventBus` over any `EventChannel`; `subscribe(listener)` → `{ unsubscribe() }`, both async |
|
|
1355
|
+
| `EventChannel`, `ChannelHandler`, `ChannelSubscription` | infrastructure | port (types) | `send(serialized)` / `receive(eventName, consumer, handler)` → `{ close() }`: the only thing a technology implements |
|
|
1356
|
+
| `EventRegistry`, `EventFactory` | infrastructure | class, type | Rebuilds an event from its JSON, keeping its `eventId` and `occurredOn` |
|
|
1357
|
+
|
|
1358
|
+
### Listeners: `EventListener` and `@ListensTo`
|
|
1359
|
+
|
|
1360
|
+
A listener is what the application does when an event happens. It extends
|
|
1361
|
+
`EventListener<Event>`, implements `handle(event)` (sync or async) and is marked with
|
|
1362
|
+
`@ListensTo(EventClass, { consumer })`, which stores the event's `static eventName` and the
|
|
1363
|
+
consumer name on the listener class. A listener listens to exactly one event (decorating it twice
|
|
1364
|
+
throws `ApplicationException`) and any number of listeners may listen to the same event. A
|
|
1365
|
+
subclass does not inherit the mark.
|
|
1366
|
+
|
|
1367
|
+
The consumer name is required (a missing, blank or non-text one throws `ApplicationException`):
|
|
1368
|
+
it is the identity the channel gives the listener, such as the name of its durable queue in a
|
|
1369
|
+
broker. Keep it stable and unique per listener: it never comes from the class name, because a
|
|
1370
|
+
minifier renames classes between builds and two listeners in different modules may share a class
|
|
1371
|
+
name, which with a broker would move the listener to a new queue or make both compete for the
|
|
1372
|
+
same messages.
|
|
1373
|
+
|
|
1374
|
+
Its name is `<Action>On<Event>`, and its body only turns the event into the input of the one use
|
|
1375
|
+
case it receives by constructor:
|
|
1376
|
+
|
|
1377
|
+
```ts
|
|
1378
|
+
import { EventListener, ListensTo } from '@archi-code/ddd/application';
|
|
1379
|
+
|
|
1380
|
+
@ListensTo(OrderPlaced, { consumer: 'reserve-stock-on-order-placed' })
|
|
1381
|
+
class ReserveStockOnOrderPlaced extends EventListener<OrderPlaced> {
|
|
1382
|
+
constructor(private readonly reserveStock: ReserveStock) {
|
|
1383
|
+
super();
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
async handle(event: OrderPlaced): Promise<void> {
|
|
1387
|
+
await this.reserveStock.execute({ orderId: event.aggregateId });
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
```
|
|
1391
|
+
|
|
1392
|
+
`ListensTo.subscriptionOf(listener)` returns the event name and the consumer name a listener
|
|
1393
|
+
instance listens with, as a `ListenerSubscription` (`{ eventName, consumer }`); it throws
|
|
1394
|
+
`ApplicationException` when its class has no `@ListensTo`. The mark is kept under a `Symbol.for`
|
|
1395
|
+
key, so a listener decorated with one build (CJS or ESM) is read by the other. Neither the
|
|
1396
|
+
aggregates nor the use cases know the listeners: only the wiring subscribes them to the bus.
|
|
1397
|
+
|
|
1398
|
+
### The ports
|
|
1399
|
+
|
|
1400
|
+
`EventBus` comes from `@archi-code/ddd/application`; the channel port (`EventChannel`,
|
|
1401
|
+
`ChannelHandler`, `ChannelSubscription`) and `Subscription` come from
|
|
1402
|
+
`@archi-code/ddd/infrastructure`:
|
|
1403
|
+
|
|
1404
|
+
```ts
|
|
1405
|
+
interface EventBus<Event extends EventBase = EventBase> {
|
|
1406
|
+
publish(events: readonly Event[]): Promise<void>;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
interface Subscription {
|
|
1410
|
+
unsubscribe(): Promise<void>;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
type ChannelHandler = (event: SerializedEvent) => Promise<void>;
|
|
1414
|
+
|
|
1415
|
+
interface ChannelSubscription {
|
|
1416
|
+
close(): Promise<void>;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
interface EventChannel {
|
|
1420
|
+
send(events: readonly SerializedEvent[]): Promise<void>;
|
|
1421
|
+
receive(
|
|
1422
|
+
eventName: string,
|
|
1423
|
+
consumer: string,
|
|
1424
|
+
handler: ChannelHandler,
|
|
1425
|
+
): Promise<ChannelSubscription>;
|
|
1426
|
+
}
|
|
1427
|
+
```
|
|
1428
|
+
|
|
1429
|
+
A channel only moves serialized events (`toJSON()`), so the same contract fits a process, a broker
|
|
1430
|
+
and a table. The rules every channel follows:
|
|
1431
|
+
|
|
1432
|
+
| Concern | Rule |
|
|
1433
|
+
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
1434
|
+
| Payload | `SerializedEvent`s (JSON-safe), never instances |
|
|
1435
|
+
| Consumer identity | `receive(eventName, consumer, handler)`; `DomainEventBus` passes the `consumer` the listener declares in `@ListensTo` (a durable queue name, a consumer row) |
|
|
1436
|
+
| One per consumer | a second `receive` for the same event and consumer while the first is open throws `InfrastructureException` |
|
|
1437
|
+
| Lifecycle | `receive` resolves to a `ChannelSubscription`; `close()` is async and can be called more than once |
|
|
1438
|
+
| `send` resolves | when the channel accepted the events; whether the handlers already ran is the channel's choice |
|
|
1439
|
+
| Order | one consumer receives the events of one `send` in their order |
|
|
1440
|
+
| Delivery | at least once: a listener ignores an `eventId` it already processed |
|
|
1441
|
+
| Handler failure | the channel's choice (the in-memory channel rejects `send`; a broker retries or dead-letters) |
|
|
1442
|
+
|
|
1443
|
+
### `DomainEventBus` and `EventRegistry`
|
|
1444
|
+
|
|
1445
|
+
Both live in `@archi-code/ddd/infrastructure` and are used only by the composition root:
|
|
1446
|
+
|
|
1447
|
+
```ts
|
|
1448
|
+
import {
|
|
1449
|
+
DomainEventBus,
|
|
1450
|
+
EventRegistry,
|
|
1451
|
+
InMemoryEventChannel,
|
|
1452
|
+
} from '@archi-code/ddd/infrastructure';
|
|
1453
|
+
|
|
1454
|
+
const events = new EventRegistry<OrderEvent>()
|
|
1455
|
+
.register(OrderPlaced, (json) => new OrderPlaced(json.aggregateId, json.customer as string, json))
|
|
1456
|
+
.register(OrderShipped, (json) => new OrderShipped(json.aggregateId, json));
|
|
1457
|
+
|
|
1458
|
+
const bus = new DomainEventBus<OrderEvent>(new InMemoryEventChannel(), events);
|
|
1459
|
+
const reserving = await bus.subscribe(new ReserveStockOnOrderPlaced(reserveStock));
|
|
1460
|
+
await bus.subscribe(new NotifyCustomerOnOrderShipped(notifyCustomer));
|
|
1461
|
+
|
|
1462
|
+
const placeOrder = new PlaceOrder(orders, bus, clock); // the use case only sees EventBus
|
|
1463
|
+
await placeOrder.execute({ customer: 'Ana' }); // ReserveStock ran with a rebuilt OrderPlaced
|
|
1464
|
+
await reserving.unsubscribe();
|
|
1465
|
+
```
|
|
1466
|
+
|
|
1467
|
+
- `DomainEventBus` implements `EventBus`; `subscribe(listener)` is its own method, called only by
|
|
1468
|
+
the wiring.
|
|
1469
|
+
- `publish(events)` sends the `toJSON()` of each event to the channel in one `send` (events
|
|
1470
|
+
recorded by an aggregate are already frozen by `record()`).
|
|
1471
|
+
- `subscribe(listener)` reads the event and the consumer with `ListensTo.subscriptionOf` (`ApplicationException` without
|
|
1472
|
+
`@ListensTo`), refuses an event the registry cannot rebuild (`UnknownEventException`, at wiring
|
|
1473
|
+
time rather than at the first delivery) and calls
|
|
1474
|
+
`channel.receive(eventName, consumer, handler)`; the handler rebuilds the event
|
|
1475
|
+
with the registry and calls `listener.handle`. `unsubscribe()` closes the channel subscription.
|
|
1476
|
+
- Listeners always receive an event rebuilt from its JSON (frozen, equal by `toJSON()`), also in
|
|
1477
|
+
memory, so what works with the in-memory channel works the same with a broker.
|
|
1478
|
+
- An optional third argument, a `Logger`, logs what was published, the subscriptions and every
|
|
1479
|
+
listener failure with its event and consumer (see [Logging](#logging)); without it, nothing is
|
|
1480
|
+
logged.
|
|
1481
|
+
|
|
1482
|
+
`EventRegistry`: `register(EventClass, factory)` (the same event twice throws
|
|
1483
|
+
`InternalErrorException`: a wiring mistake), `has(eventName)` and `fromJSON(json)` (a parsed
|
|
1484
|
+
`SerializedEvent`: turning a message body into JSON is the channel's job).
|
|
1485
|
+
The factory must pass the serialized event as the metadata of the new event (`json` as the last
|
|
1486
|
+
argument), so the rebuilt event keeps its `eventId` and `occurredOn`: consumers deduplicate by
|
|
1487
|
+
`eventId`, and a new one would make every redelivery look like a new event. `fromJSON` checks it
|
|
1488
|
+
and throws `InternalErrorException`
|
|
1489
|
+
(`the factory of "OrderPlaced" must pass the serialized event as metadata, so it keeps its eventId and occurredOn`)
|
|
1490
|
+
when the rebuilt `eventId` (compared without case) or the `occurredOn` instant differs from the
|
|
1491
|
+
JSON. It also throws `UnknownEventException` for a name nobody registered and
|
|
1492
|
+
`InternalErrorException` for text that is not a JSON object or has no `eventName`. The rebuilt
|
|
1493
|
+
event is deep-frozen, like the ones an aggregate records. The factory receives the stored
|
|
1494
|
+
`eventVersion`, so it can read older shapes (`json.eventVersion === 1 ? … : …`).
|
|
1495
|
+
|
|
1496
|
+
### Example: from the use case to the listener, in memory
|
|
1497
|
+
|
|
1498
|
+
One aggregate records `OrderPlaced`, a use case publishes it, and a listener runs the use case
|
|
1499
|
+
that sends the confirmation. The use cases and the listener only know the ports; the composition
|
|
1500
|
+
root chooses the channel:
|
|
1501
|
+
|
|
1502
|
+
```ts
|
|
1503
|
+
import { AggregateRoot, EventBase, IdType, Required, StringType } from '@archi-code/ddd/domain';
|
|
1504
|
+
import type { AggregateSaver, EventMetadata, PrimitiveTypes } from '@archi-code/ddd/domain';
|
|
1505
|
+
import { EventListener, ListensTo, UseCase } from '@archi-code/ddd/application';
|
|
1506
|
+
import type { Clock, EventBus } from '@archi-code/ddd/application';
|
|
1507
|
+
import {
|
|
1508
|
+
DomainEventBus,
|
|
1509
|
+
EventRegistry,
|
|
1510
|
+
FixedClock,
|
|
1511
|
+
InMemoryEventChannel,
|
|
1512
|
+
} from '@archi-code/ddd/infrastructure';
|
|
1513
|
+
import { InMemoryOrderRepository } from './test/support/in-memory-order-repository';
|
|
1514
|
+
|
|
1515
|
+
class OrderId extends IdType<'OrderId'> {}
|
|
1516
|
+
class CustomerName extends Required(StringType) {}
|
|
1517
|
+
|
|
1518
|
+
class OrderPlaced extends EventBase {
|
|
1519
|
+
static readonly eventName = 'OrderPlaced';
|
|
1520
|
+
|
|
1521
|
+
constructor(
|
|
1522
|
+
orderId: string,
|
|
1523
|
+
readonly customer: string,
|
|
1524
|
+
metadata?: EventMetadata,
|
|
1525
|
+
) {
|
|
1526
|
+
super(orderId, metadata);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
class Order extends AggregateRoot<OrderId, OrderPlaced> {
|
|
1531
|
+
private constructor(
|
|
1532
|
+
id: OrderId,
|
|
1533
|
+
private readonly _customer: CustomerName,
|
|
1534
|
+
) {
|
|
1535
|
+
super(id);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
static place(id: OrderId, customer: CustomerName, clock: Clock): Order {
|
|
1539
|
+
const order = new Order(id, customer);
|
|
1540
|
+
order.record(new OrderPlaced(id.value, customer.value, { clock }));
|
|
1541
|
+
return order;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
static fromPrimitives(primitives: PrimitiveTypes<Order>): Order {
|
|
1545
|
+
return new Order(new OrderId(primitives.id), new CustomerName(primitives.customer));
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
get customer(): CustomerName {
|
|
1549
|
+
return this._customer;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
toPrimitives(): PrimitiveTypes<Order> {
|
|
1553
|
+
return { id: this.id.value, customer: this.customer.value };
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
class PlaceOrder extends UseCase<{ customer: string }, CustomerName, string> {
|
|
1558
|
+
constructor(
|
|
1559
|
+
private readonly orders: AggregateSaver<Order>,
|
|
1560
|
+
private readonly events: EventBus<OrderPlaced>,
|
|
1561
|
+
private readonly clock: Clock,
|
|
1562
|
+
) {
|
|
1563
|
+
super();
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
protected toCommand(input: { customer: string }): CustomerName {
|
|
1567
|
+
return new CustomerName(input.customer);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
protected async handle(customer: CustomerName): Promise<string> {
|
|
1571
|
+
const order = Order.place(OrderId.generate(), customer, this.clock);
|
|
1572
|
+
await this.orders.save(order);
|
|
1573
|
+
await this.events.publish(order.pullDomainEvents());
|
|
1574
|
+
return order.id.value;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
class SendConfirmation extends UseCase<{ orderId: string; customer: string }, string, void> {
|
|
1579
|
+
constructor(private readonly mails: string[]) {
|
|
1580
|
+
super();
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
protected toCommand(input: { orderId: string; customer: string }): string {
|
|
1584
|
+
return `Order ${input.orderId} confirmed to ${new CustomerName(input.customer).value}`;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
protected handle(mail: string): void {
|
|
1588
|
+
this.mails.push(mail);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
@ListensTo(OrderPlaced, { consumer: 'send-confirmation-on-order-placed' })
|
|
1593
|
+
class SendConfirmationOnOrderPlaced extends EventListener<OrderPlaced> {
|
|
1594
|
+
constructor(private readonly sendConfirmation: SendConfirmation) {
|
|
1595
|
+
super();
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
async handle(event: OrderPlaced): Promise<void> {
|
|
1599
|
+
await this.sendConfirmation.execute({ orderId: event.aggregateId, customer: event.customer });
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// composition root
|
|
1604
|
+
const mails: string[] = [];
|
|
1605
|
+
const events = new EventRegistry<OrderPlaced>().register(
|
|
1606
|
+
OrderPlaced,
|
|
1607
|
+
(json) => new OrderPlaced(json.aggregateId, json.customer as string, json),
|
|
1608
|
+
);
|
|
1609
|
+
const channel = new InMemoryEventChannel();
|
|
1610
|
+
const bus = new DomainEventBus<OrderPlaced>(channel, events);
|
|
1611
|
+
const confirming = await bus.subscribe(
|
|
1612
|
+
new SendConfirmationOnOrderPlaced(new SendConfirmation(mails)),
|
|
1613
|
+
);
|
|
1614
|
+
|
|
1615
|
+
const orders = new InMemoryOrderRepository(); // see "Writing a repository"
|
|
1616
|
+
const placeOrder = new PlaceOrder(orders, bus, new FixedClock('2026-10-19T09:30:00Z'));
|
|
1617
|
+
const orderId = await placeOrder.execute({ customer: 'Ana' });
|
|
1618
|
+
|
|
1619
|
+
mails; // ['Order <orderId> confirmed to Ana']: the listener ran before publish resolved
|
|
1620
|
+
channel.sent;
|
|
1621
|
+
// [{ eventName: 'OrderPlaced', eventVersion: 1, eventId: '…', aggregateId: orderId,
|
|
1622
|
+
// occurredOn: '2026-10-19T09:30:00.000Z', customer: 'Ana' }]
|
|
1623
|
+
|
|
1624
|
+
await bus.subscribe(new SendConfirmationOnOrderPlaced(new SendConfirmation(mails)));
|
|
1625
|
+
// InfrastructureException: SendConfirmationOnOrderPlaced already receives "OrderPlaced"
|
|
1626
|
+
|
|
1627
|
+
await confirming.unsubscribe();
|
|
1628
|
+
await placeOrder.execute({ customer: 'Luis' });
|
|
1629
|
+
mails.length; // 1: nobody receives OrderPlaced any more
|
|
1630
|
+
channel.sent.length; // 2
|
|
1631
|
+
```
|
|
1632
|
+
|
|
1633
|
+
In a test, assert on what the listener caused (`mails`) and on `channel.sent`; with
|
|
1634
|
+
`new InMemoryEventChannel({ delivery: 'parallel' })` the listeners of one event run together and
|
|
1635
|
+
their failures arrive in one `EventDeliveryException`.
|
|
1636
|
+
|
|
1637
|
+
### Implementing another channel
|
|
1638
|
+
|
|
1639
|
+
A new technology implements one class, `EventChannel`, in its own infrastructure folder of your
|
|
1640
|
+
project (for example `infrastructure/messaging/RabbitMqEventChannel.ts`): `send` publishes each
|
|
1641
|
+
JSON (a message per event, a row per event…), `receive` declares what the consumer needs (a queue
|
|
1642
|
+
named after `consumer` bound to `eventName`, a consumer row…) and calls `handler` for each delivery,
|
|
1643
|
+
and `close` cancels it. The channel imports its port types (`EventChannel`, `ChannelHandler`,
|
|
1644
|
+
`ChannelSubscription`) from `@archi-code/ddd/infrastructure`.
|
|
1645
|
+
|
|
1646
|
+
A RabbitMQ channel, written against the few methods of an
|
|
1647
|
+
[amqplib](https://www.npmjs.com/package/amqplib) channel it uses (declared structurally, so the
|
|
1648
|
+
package needs no driver): one durable topic exchange, the event name as routing key, one durable
|
|
1649
|
+
queue per consumer and event, `ack` when the listener finished and `nack` without requeue when it
|
|
1650
|
+
failed (configure a dead-letter exchange on the queue to keep those messages):
|
|
1651
|
+
|
|
1652
|
+
```ts
|
|
1653
|
+
import type {
|
|
1654
|
+
ChannelHandler,
|
|
1655
|
+
ChannelSubscription,
|
|
1656
|
+
EventChannel,
|
|
1657
|
+
} from '@archi-code/ddd/infrastructure';
|
|
1658
|
+
import type { SerializedEvent } from '@archi-code/ddd/domain';
|
|
1659
|
+
|
|
1660
|
+
interface AmqpMessage {
|
|
1661
|
+
content: Buffer;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
interface AmqpChannel {
|
|
1665
|
+
assertExchange(exchange: string, type: 'topic', options: { durable: boolean }): Promise<unknown>;
|
|
1666
|
+
publish(
|
|
1667
|
+
exchange: string,
|
|
1668
|
+
routingKey: string,
|
|
1669
|
+
content: Buffer,
|
|
1670
|
+
options: { persistent: boolean; messageId: string; contentType: string },
|
|
1671
|
+
): boolean;
|
|
1672
|
+
assertQueue(queue: string, options: { durable: boolean }): Promise<unknown>;
|
|
1673
|
+
bindQueue(queue: string, exchange: string, pattern: string): Promise<unknown>;
|
|
1674
|
+
consume(
|
|
1675
|
+
queue: string,
|
|
1676
|
+
onMessage: (message: AmqpMessage | null) => void,
|
|
1677
|
+
): Promise<{ consumerTag: string }>;
|
|
1678
|
+
ack(message: AmqpMessage): void;
|
|
1679
|
+
nack(message: AmqpMessage, allUpTo: boolean, requeue: boolean): void;
|
|
1680
|
+
cancel(consumerTag: string): Promise<unknown>;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
class RabbitMqEventChannel implements EventChannel {
|
|
1684
|
+
constructor(
|
|
1685
|
+
private readonly amqp: AmqpChannel,
|
|
1686
|
+
private readonly exchange = 'sales.events',
|
|
1687
|
+
) {}
|
|
1688
|
+
|
|
1689
|
+
async send(events: readonly SerializedEvent[]): Promise<void> {
|
|
1690
|
+
await this.amqp.assertExchange(this.exchange, 'topic', { durable: true });
|
|
1691
|
+
for (const event of events) {
|
|
1692
|
+
this.amqp.publish(this.exchange, event.eventName, Buffer.from(JSON.stringify(event)), {
|
|
1693
|
+
persistent: true,
|
|
1694
|
+
messageId: event.eventId,
|
|
1695
|
+
contentType: 'application/json',
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
async receive(
|
|
1701
|
+
eventName: string,
|
|
1702
|
+
consumer: string,
|
|
1703
|
+
handler: ChannelHandler,
|
|
1704
|
+
): Promise<ChannelSubscription> {
|
|
1705
|
+
const queue = `${consumer}.${eventName}`;
|
|
1706
|
+
await this.amqp.assertExchange(this.exchange, 'topic', { durable: true });
|
|
1707
|
+
await this.amqp.assertQueue(queue, { durable: true });
|
|
1708
|
+
await this.amqp.bindQueue(queue, this.exchange, eventName);
|
|
1709
|
+
const { consumerTag } = await this.amqp.consume(queue, (message) => {
|
|
1710
|
+
if (message === null) {
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
handler(JSON.parse(message.content.toString()))
|
|
1714
|
+
.then(() => this.amqp.ack(message))
|
|
1715
|
+
.catch(() => this.amqp.nack(message, false, false));
|
|
1716
|
+
});
|
|
1717
|
+
let open = true;
|
|
1718
|
+
return {
|
|
1719
|
+
close: async () => {
|
|
1720
|
+
if (open) {
|
|
1721
|
+
open = false;
|
|
1722
|
+
await this.amqp.cancel(consumerTag);
|
|
1723
|
+
}
|
|
1724
|
+
},
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
```
|
|
1729
|
+
|
|
1730
|
+
The wiring of the example above changes one line, and the use cases and the listener stay as they
|
|
1731
|
+
are:
|
|
1732
|
+
|
|
1733
|
+
```ts
|
|
1734
|
+
const bus = new DomainEventBus<OrderPlaced>(new RabbitMqEventChannel(amqpChannel), events);
|
|
1735
|
+
```
|
|
1736
|
+
|
|
1737
|
+
With a broker, `publish` resolves when the messages were handed to RabbitMQ, not when the listener
|
|
1738
|
+
ran, so a test waits for its effect instead of reading it right after `execute`. Make your channel
|
|
1739
|
+
follow the rules of the table above; the package's own contract suite
|
|
1740
|
+
(`test/contract/event-channel-contract.ts` in the repository) shows the cases every channel must
|
|
1741
|
+
pass.
|
|
1742
|
+
|
|
1743
|
+
## Ports
|
|
1744
|
+
|
|
1745
|
+
Technical capabilities the application needs and infrastructure implements (types only):
|
|
1746
|
+
|
|
1747
|
+
```ts
|
|
1748
|
+
interface Clock {
|
|
1749
|
+
now(): Date;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
1753
|
+
type LogContext = Readonly<Record<string, unknown>>;
|
|
1754
|
+
|
|
1755
|
+
interface Logger {
|
|
1756
|
+
debug(message: string, context?: LogContext): void;
|
|
1757
|
+
info(message: string, context?: LogContext): void;
|
|
1758
|
+
warn(message: string, context?: LogContext): void;
|
|
1759
|
+
error(message: string, context?: LogContext): void;
|
|
1760
|
+
}
|
|
1761
|
+
```
|
|
1762
|
+
|
|
1763
|
+
There is no transaction port: transactions are a persistence concern, and persistence belongs to
|
|
1764
|
+
the repository (see [Persistence belongs to the repository](#persistence-belongs-to-the-repository)).
|
|
1765
|
+
|
|
1766
|
+
`Logger` is the contract any logging library implements: four levels, each taking a stable
|
|
1767
|
+
message and an optional structured context. `console` and winston already have this shape; pino
|
|
1768
|
+
(`logger.info(context, message)`) needs a four-line adapter. A use case that must log receives it
|
|
1769
|
+
by constructor, like any port; the domain never logs (it records events or throws). See
|
|
1770
|
+
[Logging](#logging) for the providers this package ships and where it logs on its own.
|
|
1771
|
+
|
|
1772
|
+
## Persistence belongs to the repository
|
|
1773
|
+
|
|
1774
|
+
A use case changes one aggregate and saves it once [IDDD ch. 10]. It never opens, joins nor closes
|
|
1775
|
+
a transaction, and receives no transaction port: it loads, calls the business method, saves and
|
|
1776
|
+
publishes.
|
|
1777
|
+
|
|
1778
|
+
```ts
|
|
1779
|
+
import { UseCase } from '@archi-code/ddd/application';
|
|
1780
|
+
import type { Clock, EventBus } from '@archi-code/ddd/application';
|
|
1781
|
+
|
|
1782
|
+
class ShipOrder extends UseCase<{ orderId: string }, OrderId, void> {
|
|
1783
|
+
constructor(
|
|
1784
|
+
private readonly orders: AggregateFinder<Order> & AggregateSaver<Order>,
|
|
1785
|
+
private readonly events: EventBus<OrderEvent>,
|
|
1786
|
+
private readonly clock: Clock,
|
|
1787
|
+
) {
|
|
1788
|
+
super();
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
protected toCommand(input: { orderId: string }): OrderId {
|
|
1792
|
+
return new OrderId(input.orderId);
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
protected async handle(orderId: OrderId): Promise<void> {
|
|
1796
|
+
const order = await this.orders.findById(orderId);
|
|
1797
|
+
if (order === null) {
|
|
1798
|
+
throw new AggregateNotFoundException('Order', orderId.value);
|
|
1799
|
+
}
|
|
1800
|
+
order.ship(this.clock);
|
|
1801
|
+
await this.orders.save(order);
|
|
1802
|
+
await this.events.publish(order.pullDomainEvents());
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
```
|
|
1806
|
+
|
|
1807
|
+
The repository is responsible for the transaction and for everything else about persistence:
|
|
1808
|
+
|
|
1809
|
+
- **Atomic writes**: each `save` and `remove` writes the whole aggregate or nothing. One document
|
|
1810
|
+
per aggregate is one atomic write; an aggregate spread over several tables is written inside a
|
|
1811
|
+
database transaction that the repository opens and commits (or rolls back) within `save` itself,
|
|
1812
|
+
with its own connection.
|
|
1813
|
+
- **Concurrency**: it checks the stored version and throws `ConcurrencyException` (see
|
|
1814
|
+
[Optimistic concurrency](#optimistic-concurrency)), calls `markPersisted()` after a write and
|
|
1815
|
+
rebuilds with `Order.fromPrimitives(row).restoreVersion(version)` on load.
|
|
1816
|
+
- **Connections and errors**: pools, sessions and driver retries stay inside the adapter, and driver
|
|
1817
|
+
errors are wrapped in `InternalErrorException` with `{ cause }`.
|
|
1818
|
+
- **Events that must not be lost**: `publish` runs after `save`, so a failure in between loses the
|
|
1819
|
+
events. When that matters, the repository writes the pending events in the same atomic write as
|
|
1820
|
+
the aggregate (an outbox in the same document or in the same database transaction of `save`) and
|
|
1821
|
+
a relay delivers them through an `EventChannel` [Destilado ch. 6; IDDD ch. 8]; use cases do not
|
|
1822
|
+
change.
|
|
1823
|
+
|
|
1824
|
+
An in-memory repository written in your tests (see [Writing a repository](#writing-a-repository))
|
|
1825
|
+
follows the same contract: every `save` and `remove` is applied whole or not at all.
|
|
1826
|
+
|
|
1827
|
+
## Event Sourcing and CQRS: only for a concrete risk
|
|
1828
|
+
|
|
1829
|
+
Neither is required, and both add complexity [IDDD ch. 4]. This package persists the state of
|
|
1830
|
+
aggregates; it does not ship an event store nor Event Sourcing support: `version` grows by 1 per
|
|
1831
|
+
save, not per event, and an event-sourced aggregate manages the version of its stream itself.
|
|
1832
|
+
|
|
1833
|
+
| Situation | Recommendation |
|
|
1834
|
+
| ------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
1835
|
+
| Screens that follow one aggregate | Repositories and use cases, nothing else |
|
|
1836
|
+
| Lists and searches across aggregates | Read queries straight to the database (SQL, views), read-only |
|
|
1837
|
+
| Views very different from the model, heavy reading | CQRS: read models kept up to date by event listeners |
|
|
1838
|
+
| A business or legal need for the full history of an aggregate | Consider Event Sourcing for that aggregate only |
|
|
1839
|
+
|
|
1840
|
+
A read model (projection) is kept up to date by `EventListener`s subscribed to the bus, which
|
|
1841
|
+
skip an `eventId` they already applied (delivery is at least once). A channel is a delivery path,
|
|
1842
|
+
not a history, so rebuilding a read model from scratch needs an event log of your own (a table
|
|
1843
|
+
where a listener appends every event, or the broker's retained stream). If a read model is eventually consistent, the screen must expect it: show what was
|
|
1844
|
+
just sent, show how old the data is, or say that the request is being processed.
|
|
1845
|
+
|
|
1846
|
+
# Infrastructure layer: `@archi-code/ddd/infrastructure`
|
|
1847
|
+
|
|
1848
|
+
Ready adapters for tests, prototypes and the edges of an API:
|
|
1849
|
+
|
|
1850
|
+
| Export | What it is |
|
|
1851
|
+
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
1852
|
+
| `InMemoryEventChannel({ delivery }?)` | `EventChannel` inside the process, built on emittery's techniques; `sent` lists what was sent |
|
|
1853
|
+
| `HttpErrorMapper({ statusByCode?, logger? }?)` | `map(error)` → `{ status, body: { code, message, errors? } }` (`errors`: `{ field, code, message }[]`) |
|
|
1854
|
+
| `ConsoleLogger({ level?, clock? }?)` | `Logger` that writes one JSON line per entry from `level` up (`info` by default) |
|
|
1855
|
+
| `InMemoryLogger`, `SilentLogger` | `Logger` that keeps `entries` for tests, and one that writes nothing (the default of every piece that logs) |
|
|
1856
|
+
| `SystemClock`, `FixedClock(at)` | `Clock` with the real time, and a test clock with `set(at)` and `advance(ms)` |
|
|
1857
|
+
|
|
1858
|
+
The package ships no repository: each project writes one per aggregate and technology (see
|
|
1859
|
+
[Writing a repository](#writing-a-repository)), including the in-memory one its tests use.
|
|
1860
|
+
|
|
1861
|
+
`HttpErrorMapper` statuses, overridable by code (`new HttpErrorMapper({ statusByCode: { ORD001: 403 } })`):
|
|
1862
|
+
|
|
1863
|
+
| Error | Status |
|
|
1864
|
+
| ---------------------------------------------------- | ------ |
|
|
1865
|
+
| `TypePrimitiveException`, `RequiredValueException` | 400 |
|
|
1866
|
+
| `NotAllowedException` | 403 |
|
|
1867
|
+
| `AggregateNotFoundException` | 404 |
|
|
1868
|
+
| `ConcurrencyException` | 409 |
|
|
1869
|
+
| `ValidationException` (body includes `errors`) | 422 |
|
|
1870
|
+
| any other `DomainException` (a broken business rule) | 422 |
|
|
1871
|
+
| any other `ApplicationException` | 400 |
|
|
1872
|
+
| `InfrastructureException` and anything unknown | 500 |
|
|
1873
|
+
|
|
1874
|
+
Every 5xx answer has the message `Internal server error`: messages of infrastructure errors (SQL,
|
|
1875
|
+
hosts, secrets…) never reach the client. Unknown errors get the code `E000`. The overrides are
|
|
1876
|
+
read only from the object you pass (a code such as `constructor` or `toString` is not taken from
|
|
1877
|
+
`Object.prototype`).
|
|
1878
|
+
|
|
1879
|
+
`InMemoryEventChannel` is the in-process `EventChannel` behind `DomainEventBus` (see
|
|
1880
|
+
[Events](#events-the-contract-in-applicationevent)). It reimplements the techniques of
|
|
1881
|
+
[emittery](https://github.com/sindresorhus/emittery) (MIT) that a channel needs: a `Map` from event
|
|
1882
|
+
name to a `Set` of handlers, dispatch always after a microtask (no handler runs inside `send`), a
|
|
1883
|
+
snapshot of the handlers taken when an event is sent (a consumer that starts receiving during the
|
|
1884
|
+
delivery is not called for it; one closed before its turn is skipped), and two delivery modes:
|
|
1885
|
+
|
|
1886
|
+
| `delivery` | Like emittery's | Behaviour |
|
|
1887
|
+
| -------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
1888
|
+
| `'serial'` (default) | `emitSerial` | Consumers run one after another, in the order they started receiving; the first failure rejects `send` with its own error |
|
|
1889
|
+
| `'parallel'` | `emit` | Consumers start together; all of them run, and the failures reject `send` with one `EventDeliveryException` (`errors` holds each) |
|
|
1890
|
+
|
|
1891
|
+
Events go one after another in the order of the `send`; a failure stops the events after it. Every
|
|
1892
|
+
consumer gets the same frozen JSON copy, `send` resolves once the handlers finished, and `sent`
|
|
1893
|
+
returns a copy of what was sent, in order (for tests).
|
|
1894
|
+
|
|
1895
|
+
```ts
|
|
1896
|
+
import {
|
|
1897
|
+
DomainEventBus,
|
|
1898
|
+
HttpErrorMapper,
|
|
1899
|
+
InMemoryEventChannel,
|
|
1900
|
+
} from '@archi-code/ddd/infrastructure';
|
|
1901
|
+
|
|
1902
|
+
const channel = new InMemoryEventChannel({ delivery: 'parallel' });
|
|
1903
|
+
const bus = new DomainEventBus<OrderEvent>(channel, events); // events: the EventRegistry of "Events"
|
|
1904
|
+
await bus.subscribe(new ReserveStockOnOrderPlaced(reserveStock));
|
|
1905
|
+
await bus.subscribe(new NotifyCustomerOnOrderPlaced(notifyCustomer));
|
|
1906
|
+
await bus.publish(order.pullDomainEvents());
|
|
1907
|
+
channel.sent.map((event) => event.eventName); // ['OrderPlaced']
|
|
1908
|
+
|
|
1909
|
+
const errors = new HttpErrorMapper({ statusByCode: { ORD001: 403 } });
|
|
1910
|
+
app.use((error, req, res, next) => {
|
|
1911
|
+
const { status, body } = errors.map(error);
|
|
1912
|
+
res.status(status).json(body);
|
|
1913
|
+
});
|
|
1914
|
+
```
|
|
1915
|
+
|
|
1916
|
+
## Logging
|
|
1917
|
+
|
|
1918
|
+
The application declares the [`Logger` port](#ports); infrastructure ships three providers and
|
|
1919
|
+
logs on its own only where nobody else sees the error:
|
|
1920
|
+
|
|
1921
|
+
| Provider | Use |
|
|
1922
|
+
| ------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
|
1923
|
+
| `ConsoleLogger({ level?, clock? }?)` | Production without a logging library, Node and browsers: one JSON line per entry on `console.<level>` |
|
|
1924
|
+
| `InMemoryLogger` | Tests: `entries` lists `{ level, message, context? }` in order (a copy) |
|
|
1925
|
+
| `SilentLogger` | Writes nothing; the default when no logger is passed |
|
|
1926
|
+
|
|
1927
|
+
```ts
|
|
1928
|
+
import type { Logger } from '@archi-code/ddd/application';
|
|
1929
|
+
import {
|
|
1930
|
+
ConsoleLogger,
|
|
1931
|
+
DomainEventBus,
|
|
1932
|
+
HttpErrorMapper,
|
|
1933
|
+
SystemClock,
|
|
1934
|
+
} from '@archi-code/ddd/infrastructure';
|
|
1935
|
+
|
|
1936
|
+
const logger: Logger = new ConsoleLogger({ level: 'info', clock: new SystemClock() });
|
|
1937
|
+
logger.warn('stock_low', { sku: 'TORCH-01', units: 2 });
|
|
1938
|
+
// console.warn: {"time":"2026-10-19T09:30:00.000Z","level":"warn","message":"stock_low","context":{"sku":"TORCH-01","units":2}}
|
|
1939
|
+
|
|
1940
|
+
const bus = new DomainEventBus<OrderEvent>(channel, registry, logger);
|
|
1941
|
+
const errors = new HttpErrorMapper({ statusByCode: { ORD001: 409 }, logger });
|
|
1942
|
+
```
|
|
1943
|
+
|
|
1944
|
+
`ConsoleLogger` drops entries below `level` (`debug` < `info` < `warn` < `error`; an unknown level
|
|
1945
|
+
throws `InternalErrorException`), writes an `Error` in the context with its `name`, `code`,
|
|
1946
|
+
`message`, `errors`, `stack` and `cause` (which `JSON.stringify` would turn into `{}`), dates and
|
|
1947
|
+
value objects through their `toJSON`, a `bigint` as text and a circular reference as
|
|
1948
|
+
`'[Circular]'`; a context it cannot read becomes `'[Unserializable]'`, so logging never throws.
|
|
1949
|
+
|
|
1950
|
+
What the package logs when it receives a logger:
|
|
1951
|
+
|
|
1952
|
+
| Where | Level | Message | Context |
|
|
1953
|
+
| ----------------- | ------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
1954
|
+
| `DomainEventBus` | `debug` | `domain_events_published` (after the channel accepted them) | `events`: `{ eventName, eventId, aggregateId }[]`, no payload |
|
|
1955
|
+
| `DomainEventBus` | `info` | `event_listener_subscribed`, `event_listener_unsubscribed` | `eventName`, `consumer` |
|
|
1956
|
+
| `DomainEventBus` | `warn` | `event_listener_rejected`: a `DomainException` or `ApplicationException` (such as `ConcurrencyException`) | `eventName`, `consumer`, `eventId`, `aggregateId`, `error` |
|
|
1957
|
+
| `DomainEventBus` | `error` | `event_listener_failed`: any other error, also an event the registry cannot rebuild | `eventName`, `consumer`, `eventId`, `aggregateId`, `error` |
|
|
1958
|
+
| `HttpErrorMapper` | `error` | `http_request_failed`: every 5xx | `status`, `code`, `error` (with its cause) |
|
|
1959
|
+
| `HttpErrorMapper` | `debug` | `http_request_rejected`: every 4xx | `status`, `code` |
|
|
1960
|
+
|
|
1961
|
+
The bus still rethrows a listener's error after logging it, so the channel keeps its delivery
|
|
1962
|
+
semantics (the in-memory channel rejects `send`, a broker retries). The rule for your own code is
|
|
1963
|
+
the same: log an error once, where it is handled (an entry point, a listener's channel), never in
|
|
1964
|
+
every layer it passes through; keep the message a stable name and put only the data you need in the
|
|
1965
|
+
context (no passwords, tokens or personal data the entry does not need). Another library plugs in
|
|
1966
|
+
by implementing `Logger`:
|
|
1967
|
+
|
|
1968
|
+
```ts
|
|
1969
|
+
import pino from 'pino';
|
|
1970
|
+
import type { LogContext, Logger } from '@archi-code/ddd/application';
|
|
1971
|
+
|
|
1972
|
+
class PinoLogger implements Logger {
|
|
1973
|
+
constructor(private readonly pino: pino.Logger) {}
|
|
1974
|
+
debug(message: string, context?: LogContext): void {
|
|
1975
|
+
this.pino.debug(context ?? {}, message);
|
|
1976
|
+
}
|
|
1977
|
+
info(message: string, context?: LogContext): void {
|
|
1978
|
+
this.pino.info(context ?? {}, message);
|
|
1979
|
+
}
|
|
1980
|
+
warn(message: string, context?: LogContext): void {
|
|
1981
|
+
this.pino.warn(context ?? {}, message);
|
|
1982
|
+
}
|
|
1983
|
+
error(message: string, context?: LogContext): void {
|
|
1984
|
+
this.pino.error(context ?? {}, message);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
```
|
|
1988
|
+
|
|
1989
|
+
## Persisting aggregates
|
|
1990
|
+
|
|
1991
|
+
The domain knows nothing about persistence: a repository receives an aggregate and stores it.
|
|
1992
|
+
Each aggregate converts itself: everything it stores is a **value object** (no raw `string`,
|
|
1993
|
+
`number`, `boolean` or `Date` fields in its state), and it exposes `toPrimitives()` and
|
|
1994
|
+
`static fromPrimitives()` (its snapshot). There is no mapper in between: the repository calls
|
|
1995
|
+
those two methods and never reads the aggregate's fields. Follow these rules for every
|
|
1996
|
+
aggregate:
|
|
1997
|
+
|
|
1998
|
+
1. **State is value objects.** Each persisted field is a value object, a composite, a list of them
|
|
1999
|
+
or a child entity (`extends Entity`) whose fields are value objects too. A flag is
|
|
2000
|
+
`class Shipped extends Required(BooleanType) {}`, never `boolean`; a moment is a `DateType` /
|
|
2001
|
+
`CreatedAt`, never `Date`.
|
|
2002
|
+
2. **Private field + public getter with the same name without `_`** (`private _customer` +
|
|
2003
|
+
`get customer()`), or a public `readonly customer`. No private state without its getter: the
|
|
2004
|
+
primitives use those names. Derived values are methods (`totalQuantity()`), never getters: a getter is a
|
|
2005
|
+
key of `PrimitiveTypes<Order>` that the stored data does not have.
|
|
2006
|
+
3. **Private constructor and two factories**: a business one (`Order.place(...)`) that validates
|
|
2007
|
+
its invariants and records events, and `static fromPrimitives(primitives: PrimitiveTypes<Order>)`
|
|
2008
|
+
that rebuilds it from stored data, building each value object from its primitive and recording no
|
|
2009
|
+
event. Because value objects validate in their constructor, a corrupt row fails right there
|
|
2010
|
+
instead of entering the model.
|
|
2011
|
+
4. **`toPrimitives(): PrimitiveTypes<Order>`** returns only plain data: `vo.value` of each value
|
|
2012
|
+
object and the primitives of each child entity, never a value object.
|
|
2013
|
+
5. **The repository calls `order.toPrimitives()` and `Order.fromPrimitives(row)`** and never touches
|
|
2014
|
+
the aggregate's fields.
|
|
2015
|
+
6. **One round-trip test per aggregate**: `Order.fromPrimitives(order.toPrimitives()).toPrimitives()`
|
|
2016
|
+
equals `order.toPrimitives()`.
|
|
2017
|
+
|
|
2018
|
+
The compiler enforces rules 1 and 4 on what is public: a getter that returns a raw `boolean` makes
|
|
2019
|
+
that key `never` in `PrimitiveTypes<Order>`, so `toPrimitives()` does not compile. Nothing checks
|
|
2020
|
+
private state at run time: `PrimitiveTypes<Order>` does not see a private field, so a private field
|
|
2021
|
+
left out of `toPrimitives()` compiles and is lost when saving. Keeping rule 2 (no private state
|
|
2022
|
+
without its getter) and the round-trip test of rule 6 is the aggregate's responsibility.
|
|
2023
|
+
|
|
2024
|
+
```ts
|
|
2025
|
+
import {
|
|
2026
|
+
AggregateRoot,
|
|
2027
|
+
BooleanType,
|
|
2028
|
+
Composite,
|
|
2029
|
+
CreatedAt,
|
|
2030
|
+
EventBase,
|
|
2031
|
+
IdType,
|
|
2032
|
+
NumberType,
|
|
2033
|
+
Required,
|
|
2034
|
+
StringType,
|
|
2035
|
+
} from '@archi-code/ddd/domain';
|
|
2036
|
+
import type { EventMetadata, PrimitiveTypes } from '@archi-code/ddd/domain';
|
|
2037
|
+
import { DomainException } from '@archi-code/ddd/exceptions';
|
|
2038
|
+
|
|
2039
|
+
class OrderId extends IdType<'OrderId'> {}
|
|
2040
|
+
class CustomerName extends Required(StringType) {}
|
|
2041
|
+
class Shipped extends Required(BooleanType) {}
|
|
2042
|
+
class Sku extends Required(StringType) {}
|
|
2043
|
+
class Quantity extends Required(NumberType) {}
|
|
2044
|
+
|
|
2045
|
+
class OrderLine extends Composite({ sku: Sku, quantity: Quantity }) {}
|
|
2046
|
+
|
|
2047
|
+
class OrderPlaced extends EventBase {
|
|
2048
|
+
static readonly eventName = 'OrderPlaced';
|
|
2049
|
+
|
|
2050
|
+
constructor(
|
|
2051
|
+
orderId: string,
|
|
2052
|
+
readonly customer: string,
|
|
2053
|
+
metadata?: EventMetadata,
|
|
2054
|
+
) {
|
|
2055
|
+
super(orderId, metadata);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
class OrderShipped extends EventBase {
|
|
2060
|
+
static readonly eventName = 'OrderShipped';
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
type OrderEvent = OrderPlaced | OrderShipped;
|
|
2064
|
+
|
|
2065
|
+
class OrderAlreadyShipped extends DomainException {
|
|
2066
|
+
constructor(orderId: string) {
|
|
2067
|
+
super(`Order ${orderId} is already shipped`, ['ORD002']);
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
class Order extends AggregateRoot<OrderId, OrderEvent> {
|
|
2072
|
+
private constructor(
|
|
2073
|
+
id: OrderId,
|
|
2074
|
+
private readonly _customer: CustomerName,
|
|
2075
|
+
private _shipped: Shipped,
|
|
2076
|
+
private _lines: OrderLine[],
|
|
2077
|
+
private readonly _placedAt: CreatedAt,
|
|
2078
|
+
) {
|
|
2079
|
+
super(id);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
static place(id: OrderId, customer: CustomerName, clock: { now(): Date }): Order {
|
|
2083
|
+
const order = new Order(id, customer, new Shipped(false), [], CreatedAt.now(clock));
|
|
2084
|
+
order.record(new OrderPlaced(id.value, customer.value, { clock }));
|
|
2085
|
+
return order;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
static fromPrimitives(primitives: PrimitiveTypes<Order>): Order {
|
|
2089
|
+
return new Order(
|
|
2090
|
+
new OrderId(primitives.id),
|
|
2091
|
+
new CustomerName(primitives.customer),
|
|
2092
|
+
new Shipped(primitives.shipped),
|
|
2093
|
+
primitives.lines.map((line) => OrderLine.fromPrimitives(line)),
|
|
2094
|
+
new CreatedAt(primitives.placedAt),
|
|
2095
|
+
);
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
get customer(): CustomerName {
|
|
2099
|
+
return this._customer;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
get shipped(): Shipped {
|
|
2103
|
+
return this._shipped;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
get lines(): OrderLine[] {
|
|
2107
|
+
return [...this._lines];
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
get placedAt(): CreatedAt {
|
|
2111
|
+
return this._placedAt;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
addLine(line: OrderLine): void {
|
|
2115
|
+
if (this._shipped.value) {
|
|
2116
|
+
throw new OrderAlreadyShipped(this.id.value);
|
|
2117
|
+
}
|
|
2118
|
+
this._lines = [...this._lines, line];
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
ship(clock: { now(): Date }): void {
|
|
2122
|
+
if (this._shipped.value) {
|
|
2123
|
+
throw new OrderAlreadyShipped(this.id.value);
|
|
2124
|
+
}
|
|
2125
|
+
this._shipped = new Shipped(true);
|
|
2126
|
+
this.record(new OrderShipped(this.id.value, { clock }));
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
totalQuantity(): number {
|
|
2130
|
+
return this._lines.reduce((total, line) => total + line.quantity.value, 0);
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
toPrimitives(): PrimitiveTypes<Order> {
|
|
2134
|
+
return {
|
|
2135
|
+
id: this.id.value,
|
|
2136
|
+
customer: this.customer.value,
|
|
2137
|
+
shipped: this.shipped.value,
|
|
2138
|
+
lines: this._lines.map((line) => line.toPrimitives()),
|
|
2139
|
+
placedAt: this.placedAt.value,
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
const order = Order.place(OrderId.generate(), new CustomerName('Ana'), { now: () => new Date() });
|
|
2145
|
+
const stored = order.toPrimitives(); // plain data the repository writes with order.version + 1
|
|
2146
|
+
Order.fromPrimitives(stored).restoreVersion(1); // what the repository returns on load
|
|
2147
|
+
```
|
|
2148
|
+
|
|
2149
|
+
### Writing a repository
|
|
2150
|
+
|
|
2151
|
+
A repository is written once per aggregate and per technology, and implements the roles of the
|
|
2152
|
+
domain it supports (`AggregateFinder`, `AggregateSaver`, `AggregateRemover`):
|
|
2153
|
+
|
|
2154
|
+
- **One per aggregate**: it knows the aggregate it rebuilds, so nothing tells it which one. There
|
|
2155
|
+
is no generic repository shared by several aggregates.
|
|
2156
|
+
- **The constructor receives only its technology** (a Mongo collection, a Postgres pool…), never
|
|
2157
|
+
an aggregate class nor a mapper. Aggregates only go in and out through its methods: `save(order)`,
|
|
2158
|
+
`remove(order)`, `findById(id)` → `Order | null`.
|
|
2159
|
+
- **The aggregate converts itself**: `order.toPrimitives()` and `Order.fromPrimitives(row)` are
|
|
2160
|
+
the aggregate's snapshot, never a domain service nor a mapper. The repository calls them
|
|
2161
|
+
directly, gives back the stored version with `restoreVersion(version)` and maps the primitives to
|
|
2162
|
+
its storage format.
|
|
2163
|
+
- **It owns persistence**: atomic writes, the version check, connections and driver errors (see
|
|
2164
|
+
[Persistence belongs to the repository](#persistence-belongs-to-the-repository)).
|
|
2165
|
+
|
|
2166
|
+
The in-memory repository of your tests is written the same way, in `test/support/`:
|
|
2167
|
+
|
|
2168
|
+
```ts
|
|
2169
|
+
import type {
|
|
2170
|
+
AggregateFinder,
|
|
2171
|
+
AggregateRemover,
|
|
2172
|
+
AggregateSaver,
|
|
2173
|
+
PrimitiveTypes,
|
|
2174
|
+
} from '@archi-code/ddd/domain';
|
|
2175
|
+
import { ConcurrencyException } from '@archi-code/ddd/exceptions';
|
|
2176
|
+
|
|
2177
|
+
export class InMemoryOrderRepository
|
|
2178
|
+
implements AggregateFinder<Order>, AggregateSaver<Order>, AggregateRemover<Order>
|
|
2179
|
+
{
|
|
2180
|
+
private readonly rows = new Map<string, { version: number; primitives: PrimitiveTypes<Order> }>();
|
|
2181
|
+
|
|
2182
|
+
async findById(id: OrderId): Promise<Order | null> {
|
|
2183
|
+
const row = this.rows.get(id.value);
|
|
2184
|
+
return row === undefined
|
|
2185
|
+
? null
|
|
2186
|
+
: Order.fromPrimitives(structuredClone(row.primitives)).restoreVersion(row.version);
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
async save(order: Order): Promise<void> {
|
|
2190
|
+
const key = this.currentKey(order);
|
|
2191
|
+
const primitives = structuredClone(order.toPrimitives());
|
|
2192
|
+
this.rows.set(key, { version: order.version + 1, primitives });
|
|
2193
|
+
order.markPersisted();
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
async remove(order: Order): Promise<void> {
|
|
2197
|
+
this.rows.delete(this.currentKey(order));
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
private currentKey(order: Order): string {
|
|
2201
|
+
const key = order.id.value;
|
|
2202
|
+
if ((this.rows.get(key)?.version ?? 0) !== order.version) {
|
|
2203
|
+
throw new ConcurrencyException('Order', key, order.version);
|
|
2204
|
+
}
|
|
2205
|
+
return key;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
const orders = new InMemoryOrderRepository();
|
|
2210
|
+
await orders.save(Order.place(new OrderId(id), new CustomerName('Ana'), clock));
|
|
2211
|
+
const byAna = (await orders.findById(new OrderId(id)))!; // version 1, a copy
|
|
2212
|
+
const byLuis = (await orders.findById(new OrderId(id)))!; // version 1, another copy
|
|
2213
|
+
byAna.ship(clock);
|
|
2214
|
+
await orders.save(byAna); // stored as version 2
|
|
2215
|
+
byLuis.addLine(new OrderLine({ sku: new Sku('TORCH-01'), quantity: new Quantity(1) }));
|
|
2216
|
+
await orders.save(byLuis);
|
|
2217
|
+
// ConcurrencyException: Order with ID … was changed by someone else: expected version 1.
|
|
2218
|
+
```
|
|
2219
|
+
|
|
2220
|
+
A Mongo repository has the same shape; only the storage changes:
|
|
2221
|
+
|
|
2222
|
+
```ts
|
|
2223
|
+
class MongoOrderRepository
|
|
2224
|
+
implements AggregateFinder<Order>, AggregateSaver<Order>, AggregateRemover<Order>
|
|
2225
|
+
{
|
|
2226
|
+
constructor(private readonly collection: Collection<OrderDocument>) {}
|
|
2227
|
+
|
|
2228
|
+
async findById(id: OrderId): Promise<Order | null> {
|
|
2229
|
+
const document = await this.collection.findOne({ _id: id.value });
|
|
2230
|
+
return document === null
|
|
2231
|
+
? null
|
|
2232
|
+
: Order.fromPrimitives(document.data).restoreVersion(document.version);
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
// save: updateOne({ _id, version: order.version }, { $set: { data, version: order.version + 1 } }),
|
|
2236
|
+
// or insertOne when order.version is 0; no match → ConcurrencyException; then order.markPersisted()
|
|
2237
|
+
}
|
|
2238
|
+
```
|
|
2239
|
+
|
|
2240
|
+
# Exceptions: `@archi-code/ddd/exceptions`
|
|
2241
|
+
|
|
2242
|
+
All of them extend `AbstractException` (which extends `Error`) and have:
|
|
2243
|
+
|
|
2244
|
+
| Member | Description |
|
|
2245
|
+
| ------------- | --------------------------------------------------------------------------------------------- |
|
|
2246
|
+
| `name` | Class name |
|
|
2247
|
+
| `message` | Message |
|
|
2248
|
+
| `code` | The last code in the chain (`'APP002'`, or yours if you pass one) |
|
|
2249
|
+
| `description` | Every code in readable form: `'Application Exception (APP000), Aggregate Not Found (APP002)'` |
|
|
2250
|
+
| `timestamp` | Creation `Date` (a new copy on every read, so it cannot be changed) |
|
|
2251
|
+
| `cause` | The error passed as `{ cause }`, if any |
|
|
2252
|
+
| `print()` | `'[<description>]: <message>, <ISO timestamp>'` |
|
|
2253
|
+
| `toJSON()` | `{ name, message, code, description, timestamp }` (`timestamp` in ISO 8601) |
|
|
2254
|
+
|
|
2255
|
+
| Class | Extends | Constructor | Codes | Message |
|
|
2256
|
+
| ---------------------------- | ------------------------- | -------------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------- |
|
|
2257
|
+
| `DomainException` | `AbstractException` | `(message, exceptionCodes = [], options?)` | `DOM000` + yours | `message` |
|
|
2258
|
+
| `ValidationException` | `DomainException` | `(errors: ValidationIssueInput[])`, keeps them as `ValidationIssue`s | `DOM000`, `DOM001` | `Validation failed, <field: message, joined with ", ">` |
|
|
2259
|
+
| `TypePrimitiveException` | `DomainException` | `(expectedType, receivedValue, template?)` | `DOM000`, `DOM003` | `Validation Error: Expected a valid <expectedType>, but received <value>.` |
|
|
2260
|
+
| `RequiredValueException` | `DomainException` | `(typeName, receivedValue)` | `DOM000`, `DOM004` | `Validation Error: <typeName> is required, but received <value>.` |
|
|
2261
|
+
| `ApplicationException` | `AbstractException` | `(message, exceptionCodes = [], options?)` | `APP000` + yours | `message` |
|
|
2262
|
+
| `InfrastructureException` | `AbstractException` | `(message, exceptionCodes = [], options?)` | `INF000` + yours | `message` |
|
|
2263
|
+
| `InternalErrorException` | `InfrastructureException` | `(message, options?)` | `INF000`, `INF001` | `Failed, internal error: <message>` |
|
|
2264
|
+
| `ConcurrencyException` | `ApplicationException` | `(aggregateName, id, expectedVersion, options?)` | `APP000`, `APP001` | `<aggregateName> with ID <id> was changed by someone else: expected version <n>.` |
|
|
2265
|
+
| `AggregateNotFoundException` | `ApplicationException` | `(entity: string, id: string, options?)` | `APP000`, `APP002` | `<entity> with ID <id> not found.` |
|
|
2266
|
+
| `NotAllowedException` | `ApplicationException` | `(message, options?)` | `APP000`, `APP003` | `message` |
|
|
2267
|
+
| `UnknownEventException` | `InfrastructureException` | `(eventName)` | `INF000`, `INF002` | `Unknown event "<eventName>": register it before reading it.` |
|
|
2268
|
+
| `EventDeliveryException` | `InfrastructureException` | `(eventName, errors, consumers)` | `INF000`, `INF003` | `<n> of <consumers> consumers of "<eventName>" failed.` (`errors`: each failure) |
|
|
2269
|
+
|
|
2270
|
+
`options` is `{ cause }`: keep the original error when you wrap it. A `ValidationIssueInput` is a
|
|
2271
|
+
`ValidationIssue` (`{ field: string | null; code: string; message: string }`) whose `code` is
|
|
2272
|
+
optional: it defaults to `'DOM001'`. `TypePrimitiveException` and
|
|
2273
|
+
`RequiredValueException` are the ones value objects throw; the `template` of
|
|
2274
|
+
`TypePrimitiveException` replaces `'Expected a valid '`. Both quote at most 64 characters of the
|
|
2275
|
+
value they received (`"…"` marks the cut), so a long text pasted into a field, or a whole object,
|
|
2276
|
+
is never copied into logs or HTTP answers. The built-in codes are exported as the
|
|
2277
|
+
`ExceptionCode` enum (`ExceptionCode.AggregateNotFound === 'APP002'`).
|
|
2278
|
+
|
|
2279
|
+
`AggregateNotFoundException` and `NotAllowedException` belong to the application: a use case
|
|
2280
|
+
throws the first when the aggregate it needs is not there (a repository returns `null`) and the
|
|
2281
|
+
second when the requester may not run the action (authorization is the use case's job; a rule of
|
|
2282
|
+
the business, such as "only pending orders can be cancelled", stays in the aggregate).
|
|
2283
|
+
|
|
2284
|
+
The built-in classes recognize their instances across the CJS and ESM builds: an
|
|
2285
|
+
`AggregateNotFoundException` thrown by the ESM build is `instanceof` the CJS `ApplicationException` and
|
|
2286
|
+
`AggregateNotFoundException` (they match by code). Your own subclasses use the normal `instanceof`.
|
|
2287
|
+
|
|
2288
|
+
```ts
|
|
2289
|
+
import {
|
|
2290
|
+
AggregateNotFoundException,
|
|
2291
|
+
ApplicationException,
|
|
2292
|
+
DomainException,
|
|
2293
|
+
ExceptionCode,
|
|
2294
|
+
InternalErrorException,
|
|
2295
|
+
} from '@archi-code/ddd/exceptions';
|
|
2296
|
+
|
|
2297
|
+
const notFound = new AggregateNotFoundException('User', '42');
|
|
2298
|
+
notFound.code === ExceptionCode.AggregateNotFound; // true
|
|
2299
|
+
notFound.message; // 'User with ID 42 not found.'
|
|
2300
|
+
notFound.code; // 'APP002'
|
|
2301
|
+
notFound.description; // 'Application Exception (APP000), Aggregate Not Found (APP002)'
|
|
2302
|
+
notFound instanceof ApplicationException; // true
|
|
2303
|
+
|
|
2304
|
+
new InternalErrorException('timeout').toJSON();
|
|
2305
|
+
// {
|
|
2306
|
+
// name: 'InternalErrorException',
|
|
2307
|
+
// message: 'Failed, internal error: timeout',
|
|
2308
|
+
// code: 'INF001',
|
|
2309
|
+
// description: 'Infrastructure Exception (INF000), Internal Error (INF001)',
|
|
2310
|
+
// timestamp: '2026-10-13T11:52:00.428Z'
|
|
2311
|
+
// }
|
|
2312
|
+
|
|
2313
|
+
try {
|
|
2314
|
+
await collection.insertOne(document);
|
|
2315
|
+
} catch (error) {
|
|
2316
|
+
throw new InternalErrorException('saving order', { cause: error });
|
|
2317
|
+
}
|
|
2318
|
+
```
|
|
2319
|
+
|
|
2320
|
+
Custom exceptions: extend the one for their layer and pass your codes. In `description` a custom
|
|
2321
|
+
code appears with the class name:
|
|
2322
|
+
|
|
2323
|
+
```ts
|
|
2324
|
+
class OrderCancelledException extends DomainException {
|
|
2325
|
+
constructor(orderId: string) {
|
|
2326
|
+
super(`Order ${orderId} is cancelled`, ['ORD001']);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
const error = new OrderCancelledException('7');
|
|
2331
|
+
error.code; // 'ORD001'
|
|
2332
|
+
error.description; // 'Domain Exception (DOM000), OrderCancelledException (ORD001)'
|
|
2333
|
+
```
|
|
2334
|
+
|
|
2335
|
+
## Full example
|
|
2336
|
+
|
|
2337
|
+
```ts
|
|
2338
|
+
import { Validations } from '@archi-code/validation';
|
|
2339
|
+
import {
|
|
2340
|
+
AggregateRoot,
|
|
2341
|
+
BooleanType,
|
|
2342
|
+
CreatedAt,
|
|
2343
|
+
EventBase,
|
|
2344
|
+
IdType,
|
|
2345
|
+
Required,
|
|
2346
|
+
StringType,
|
|
2347
|
+
ValueObjects,
|
|
2348
|
+
} from '@archi-code/ddd/domain';
|
|
2349
|
+
import { AggregateNotFoundException, DomainException } from '@archi-code/ddd/exceptions';
|
|
2350
|
+
import type {
|
|
2351
|
+
AggregateFinder,
|
|
2352
|
+
AggregateSaver,
|
|
2353
|
+
EventMetadata,
|
|
2354
|
+
PrimitiveTypes,
|
|
2355
|
+
} from '@archi-code/ddd/domain';
|
|
2356
|
+
import { EventListener, ListensTo, UseCase } from '@archi-code/ddd/application';
|
|
2357
|
+
import type { Clock, EventBus } from '@archi-code/ddd/application';
|
|
2358
|
+
import {
|
|
2359
|
+
DomainEventBus,
|
|
2360
|
+
EventRegistry,
|
|
2361
|
+
FixedClock,
|
|
2362
|
+
HttpErrorMapper,
|
|
2363
|
+
InMemoryEventChannel,
|
|
2364
|
+
} from '@archi-code/ddd/infrastructure';
|
|
2365
|
+
import { InMemoryUserRepository } from './test/support/in-memory-user-repository';
|
|
2366
|
+
|
|
2367
|
+
class UserId extends IdType<'UserId'> {}
|
|
2368
|
+
class Name extends Required(StringType) {}
|
|
2369
|
+
class Blocked extends Required(BooleanType) {}
|
|
2370
|
+
|
|
2371
|
+
@Validations([{ validator: 'isEmail' }])
|
|
2372
|
+
class Email extends Required(StringType) {}
|
|
2373
|
+
|
|
2374
|
+
class UserRegistered extends EventBase {
|
|
2375
|
+
static readonly eventName = 'UserRegistered';
|
|
2376
|
+
|
|
2377
|
+
constructor(
|
|
2378
|
+
userId: string,
|
|
2379
|
+
readonly email: string,
|
|
2380
|
+
metadata?: EventMetadata,
|
|
2381
|
+
) {
|
|
2382
|
+
super(userId, metadata);
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
class UserRenamed extends EventBase {
|
|
2387
|
+
static readonly eventName = 'UserRenamed';
|
|
2388
|
+
|
|
2389
|
+
constructor(
|
|
2390
|
+
userId: string,
|
|
2391
|
+
readonly name: string,
|
|
2392
|
+
metadata?: EventMetadata,
|
|
2393
|
+
) {
|
|
2394
|
+
super(userId, metadata);
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
type UserEvent = UserRegistered | UserRenamed;
|
|
2399
|
+
|
|
2400
|
+
class UserBlockedException extends DomainException {
|
|
2401
|
+
constructor(userId: string) {
|
|
2402
|
+
super(`User ${userId} is blocked`, ['USR001']);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
class User extends AggregateRoot<UserId, UserEvent> {
|
|
2407
|
+
private constructor(
|
|
2408
|
+
id: UserId,
|
|
2409
|
+
private _name: Name,
|
|
2410
|
+
readonly email: Email,
|
|
2411
|
+
private _blocked: Blocked,
|
|
2412
|
+
readonly createdAt: CreatedAt,
|
|
2413
|
+
) {
|
|
2414
|
+
super(id);
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
static register(name: Name, email: Email, clock: { now(): Date }): User {
|
|
2418
|
+
const user = new User(UserId.generate(), name, email, new Blocked(false), CreatedAt.now(clock));
|
|
2419
|
+
user.record(new UserRegistered(user.id.value, email.value, { clock }));
|
|
2420
|
+
return user;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
static fromPrimitives(primitives: PrimitiveTypes<User>): User {
|
|
2424
|
+
return new User(
|
|
2425
|
+
new UserId(primitives.id),
|
|
2426
|
+
new Name(primitives.name),
|
|
2427
|
+
new Email(primitives.email),
|
|
2428
|
+
new Blocked(primitives.blocked),
|
|
2429
|
+
new CreatedAt(primitives.createdAt),
|
|
2430
|
+
);
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
get name(): Name {
|
|
2434
|
+
return this._name;
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
get blocked(): Blocked {
|
|
2438
|
+
return this._blocked;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
rename(name: Name, clock: { now(): Date }): void {
|
|
2442
|
+
if (this._blocked.value) {
|
|
2443
|
+
throw new UserBlockedException(this.id.value);
|
|
2444
|
+
}
|
|
2445
|
+
this._name = name;
|
|
2446
|
+
this.record(new UserRenamed(this.id.value, name.value, { clock }));
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
toPrimitives(): PrimitiveTypes<User> {
|
|
2450
|
+
return {
|
|
2451
|
+
id: this.id.value,
|
|
2452
|
+
name: this.name.value,
|
|
2453
|
+
email: this.email.value,
|
|
2454
|
+
blocked: this.blocked.value,
|
|
2455
|
+
createdAt: this.createdAt.value,
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
interface RegisterUserCommand {
|
|
2461
|
+
name: Name;
|
|
2462
|
+
email: Email;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
class RegisterUser extends UseCase<{ name: string; email: string }, RegisterUserCommand, string> {
|
|
2466
|
+
constructor(
|
|
2467
|
+
private readonly users: AggregateSaver<User>,
|
|
2468
|
+
private readonly bus: EventBus<UserEvent>,
|
|
2469
|
+
private readonly clock: Clock,
|
|
2470
|
+
) {
|
|
2471
|
+
super();
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
protected toCommand(input: { name: string; email: string }): RegisterUserCommand {
|
|
2475
|
+
return ValueObjects.build({
|
|
2476
|
+
name: () => new Name(input.name),
|
|
2477
|
+
email: () => new Email(input.email),
|
|
2478
|
+
});
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
protected async handle(command: RegisterUserCommand): Promise<string> {
|
|
2482
|
+
const user = User.register(command.name, command.email, this.clock);
|
|
2483
|
+
await this.users.save(user);
|
|
2484
|
+
await this.bus.publish(user.pullDomainEvents());
|
|
2485
|
+
return user.id.value;
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
interface RenameUserCommand {
|
|
2490
|
+
id: UserId;
|
|
2491
|
+
name: Name;
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
class RenameUser extends UseCase<{ id: string; name: string }, RenameUserCommand, void> {
|
|
2495
|
+
constructor(
|
|
2496
|
+
private readonly users: AggregateFinder<User> & AggregateSaver<User>,
|
|
2497
|
+
private readonly bus: EventBus<UserEvent>,
|
|
2498
|
+
private readonly clock: Clock,
|
|
2499
|
+
) {
|
|
2500
|
+
super();
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
protected toCommand(input: { id: string; name: string }): RenameUserCommand {
|
|
2504
|
+
return { id: new UserId(input.id), name: new Name(input.name) };
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
protected async handle(command: RenameUserCommand): Promise<void> {
|
|
2508
|
+
const user = await this.users.findById(command.id);
|
|
2509
|
+
if (user === null) {
|
|
2510
|
+
throw new AggregateNotFoundException('User', command.id.value);
|
|
2511
|
+
}
|
|
2512
|
+
user.rename(command.name, this.clock);
|
|
2513
|
+
await this.users.save(user);
|
|
2514
|
+
await this.bus.publish(user.pullDomainEvents());
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
class SendWelcomeEmail extends UseCase<{ email: string }, Email, void> {
|
|
2519
|
+
constructor(private readonly outbox: string[]) {
|
|
2520
|
+
super();
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
protected toCommand(input: { email: string }): Email {
|
|
2524
|
+
return new Email(input.email);
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
protected handle(email: Email): void {
|
|
2528
|
+
this.outbox.push(`Welcome, ${email.value}`);
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
@ListensTo(UserRegistered, { consumer: 'send-welcome-email-on-user-registered' })
|
|
2533
|
+
class SendWelcomeEmailOnUserRegistered extends EventListener<UserRegistered> {
|
|
2534
|
+
constructor(private readonly sendWelcomeEmail: SendWelcomeEmail) {
|
|
2535
|
+
super();
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
async handle(event: UserRegistered): Promise<void> {
|
|
2539
|
+
await this.sendWelcomeEmail.execute({ email: event.email });
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
const emails: string[] = [];
|
|
2544
|
+
const users = new InMemoryUserRepository(); // written like InMemoryOrderRepository
|
|
2545
|
+
const channel = new InMemoryEventChannel();
|
|
2546
|
+
const events = new EventRegistry<UserEvent>()
|
|
2547
|
+
.register(
|
|
2548
|
+
UserRegistered,
|
|
2549
|
+
(json) => new UserRegistered(json.aggregateId, json.email as string, json),
|
|
2550
|
+
)
|
|
2551
|
+
.register(UserRenamed, (json) => new UserRenamed(json.aggregateId, json.name as string, json));
|
|
2552
|
+
const bus = new DomainEventBus<UserEvent>(channel, events);
|
|
2553
|
+
await bus.subscribe(new SendWelcomeEmailOnUserRegistered(new SendWelcomeEmail(emails)));
|
|
2554
|
+
const clock = new FixedClock('2026-01-01T09:00:00Z');
|
|
2555
|
+
|
|
2556
|
+
const id = await new RegisterUser(users, bus, clock).execute({
|
|
2557
|
+
name: 'Ana',
|
|
2558
|
+
email: 'ana@example.com',
|
|
2559
|
+
});
|
|
2560
|
+
await new RenameUser(users, bus, clock).execute({ id, name: 'Ana María' });
|
|
2561
|
+
|
|
2562
|
+
channel.sent.map((event) => event.eventName); // ['UserRegistered', 'UserRenamed']
|
|
2563
|
+
emails; // ['Welcome, ana@example.com']
|
|
2564
|
+
(await users.findById(new UserId(id)))?.toPrimitives();
|
|
2565
|
+
// { id, name: 'Ana María', email: 'ana@example.com', blocked: false, createdAt: 2026-01-01T09:00:00.000Z }
|
|
2566
|
+
|
|
2567
|
+
const errors = new HttpErrorMapper();
|
|
2568
|
+
await new RenameUser(users, bus, clock)
|
|
2569
|
+
.execute({ id: UserId.random(), name: 'Eva' })
|
|
2570
|
+
.catch((error) => errors.map(error));
|
|
2571
|
+
// { status: 404, body: { code: 'APP002', message: 'User with ID … not found.' } }
|
|
2572
|
+
await new RegisterUser(users, bus, clock)
|
|
2573
|
+
.execute({ name: 'Eva', email: 'x' })
|
|
2574
|
+
.catch((error) => errors.map(error));
|
|
2575
|
+
// { status: 422, body: { code: 'DOM001', message: 'Validation failed, email: Email: …', errors: [{ field: 'email', code: 'isEmail', message: 'Email: Value does not satisfy isEmail' }] } }
|
|
2576
|
+
```
|
|
2577
|
+
|
|
2578
|
+
## Exports
|
|
2579
|
+
|
|
2580
|
+
`@archi-code/ddd/exceptions`:
|
|
2581
|
+
|
|
2582
|
+
| Export | Kind |
|
|
2583
|
+
| -------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
|
|
2584
|
+
| `AbstractException` | abstract class |
|
|
2585
|
+
| `DomainException`, `ApplicationException`, `InfrastructureException` | classes |
|
|
2586
|
+
| `ValidationException`, `AggregateNotFoundException`, `TypePrimitiveException`, `RequiredValueException`, `InternalErrorException` | classes |
|
|
2587
|
+
| `ConcurrencyException`, `NotAllowedException`, `UnknownEventException`, `EventDeliveryException` | classes |
|
|
2588
|
+
| `ExceptionCode` | enum |
|
|
2589
|
+
| `ExceptionOptions`, `ValidationIssue` (`{ field: string \| null; code: string; message: string }`), `ValidationIssueInput` (`code` optional) | types |
|
|
2590
|
+
|
|
2591
|
+
`@archi-code/ddd/domain`:
|
|
2592
|
+
|
|
2593
|
+
| Export | Kind |
|
|
2594
|
+
| --------------------------------------------------------------------------------------------------------------- | ------------------ |
|
|
2595
|
+
| `AbstractType` | abstract class |
|
|
2596
|
+
| `StringType`, `NumberType`, `BooleanType`, `DateType`, `UuidType`, `JsonType` | classes |
|
|
2597
|
+
| `EnumType`, `ArrayType` | abstract classes |
|
|
2598
|
+
| `CreatedAt`, `UpdatedAt`, `IdType` | classes (required) |
|
|
2599
|
+
| `Required`, `Composite` | functions |
|
|
2600
|
+
| `Specification` | abstract class |
|
|
2601
|
+
| `CompositeSchema`, `CompositeProps`, `CompositePrimitives`, `CompositeInput`, `CompositeType`, `CompositeValue` | types |
|
|
2602
|
+
| `ValueObjects` (`build`, `buildList`) | class |
|
|
2603
|
+
| `Immutable` (`freeze`, `date`) | class |
|
|
2604
|
+
| `Entity`, `AggregateRoot`, `EventBase` | abstract classes |
|
|
2605
|
+
| `AggregateFinder`, `AggregateSaver`, `AggregateRemover` | types (contracts) |
|
|
2606
|
+
| `RequiredType`, `JsonTypeValue`, `PrimitiveType`, `PrimitiveTypes`, `DataTypes` | types |
|
|
2607
|
+
| `EventMetadata`, `SerializedEvent`, `EventClass` | types |
|
|
2608
|
+
| `EntityId`, `SatisfiedBy` | types |
|
|
2609
|
+
| `Properties`, `PropertiesData`, `MutablePropertiesData`, `Methods`, `MethodsAndProperties` | types |
|
|
2610
|
+
|
|
2611
|
+
`@archi-code/ddd/application`:
|
|
2612
|
+
|
|
2613
|
+
| Export | Kind |
|
|
2614
|
+
| -------------------------- | -------------------------------- |
|
|
2615
|
+
| `UseCase`, `EventListener` | abstract classes |
|
|
2616
|
+
| `ListensTo` | decorator |
|
|
2617
|
+
| `ListensToOptions` | type (`{ consumer }`) |
|
|
2618
|
+
| `ListenerSubscription` | type (`{ eventName, consumer }`) |
|
|
2619
|
+
| `EventBus` | type (event port) |
|
|
2620
|
+
| `Clock`, `Logger` | types (ports) |
|
|
2621
|
+
| `LogLevel`, `LogContext` | types (logger port) |
|
|
2622
|
+
|
|
2623
|
+
`@archi-code/ddd/infrastructure`:
|
|
2624
|
+
|
|
2625
|
+
| Export | Kind |
|
|
2626
|
+
| ---------------------------------------------------------------------- | ---------------------- |
|
|
2627
|
+
| `InMemoryEventChannel`, `HttpErrorMapper`, `SystemClock`, `FixedClock` | classes |
|
|
2628
|
+
| `ConsoleLogger`, `InMemoryLogger`, `SilentLogger` | classes (loggers) |
|
|
2629
|
+
| `DomainEventBus`, `EventRegistry` | classes (event wiring) |
|
|
2630
|
+
| `EventChannel`, `ChannelHandler`, `ChannelSubscription` | types (channel port) |
|
|
2631
|
+
| `Subscription`, `EventFactory` | types |
|
|
2632
|
+
| `HttpError`, `HttpErrorBody`, `InMemoryEventChannelOptions` | types |
|
|
2633
|
+
| `HttpErrorMapperOptions`, `ConsoleLoggerOptions`, `LogEntry` | types |
|
|
2634
|
+
|
|
2635
|
+
## AI assistant skill
|
|
2636
|
+
|
|
2637
|
+
The package includes `SKILL.md`, a Claude Code skill with the rules, patterns and common mistakes of
|
|
2638
|
+
`@archi-code/ddd`, and `ddd-skills/`, a suite of Domain-Driven Design skills (discovery and Event
|
|
2639
|
+
Storming, subdomains and bounded contexts, context mapping, architecture, value objects and
|
|
2640
|
+
entities, aggregates, events, repositories, application services, event sourcing and CQRS, audits;
|
|
2641
|
+
written in Spanish, each rule with its source in Vernon's books and _DDD in PHP_). `SKILL.md` is
|
|
2642
|
+
the only skill you install and the only one you call: it classifies each request, reads the
|
|
2643
|
+
matching skill of the suite from `node_modules/@archi-code/ddd/ddd-skills/`, and turns the
|
|
2644
|
+
suite's examples into code that uses this package. For the full API it points to this README
|
|
2645
|
+
inside `node_modules/@archi-code/ddd`, so both stay valid when you update the package. Its rules,
|
|
2646
|
+
patterns and common mistakes are split into `ddd-skills/archi-code-ddd/` (where each class goes,
|
|
2647
|
+
the translation from the suite, the rules per layer, patterns, what not to do), which `SKILL.md`
|
|
2648
|
+
reads from the package too. Do not copy `ddd-skills/` into `.claude/skills/`. To install it in your project:
|
|
2649
|
+
|
|
2650
|
+
```bash
|
|
2651
|
+
mkdir -p .claude/skills/archi-code-ddd && cp node_modules/@archi-code/ddd/SKILL.md .claude/skills/archi-code-ddd/SKILL.md
|
|
2652
|
+
```
|
|
2653
|
+
|
|
2654
|
+
### How to use
|
|
2655
|
+
|
|
2656
|
+
Invoke it with a request:
|
|
2657
|
+
|
|
2658
|
+
```text
|
|
2659
|
+
/archi-code-ddd create a required Email value object that validates the format
|
|
2660
|
+
```
|
|
2661
|
+
|
|
2662
|
+
Or ask for the task without naming it, and Claude loads it on its own because it matches its
|
|
2663
|
+
description:
|
|
2664
|
+
|
|
2665
|
+
```text
|
|
2666
|
+
Add a ChangeEmail use case that loads the User, records an EmailChanged event and saves it
|
|
2667
|
+
```
|
|
2668
|
+
|
|
2669
|
+
Design questions go through the same skill, which reads the right part of the suite first:
|
|
2670
|
+
|
|
2671
|
+
```text
|
|
2672
|
+
/archi-code-ddd we sell welding courses and simulators: which bounded contexts should we have?
|
|
2673
|
+
```
|
|
2674
|
+
|
|
2675
|
+
It also works for reviewing code that already uses the package:
|
|
2676
|
+
|
|
2677
|
+
```text
|
|
2678
|
+
/archi-code-ddd review src/domain and fix whatever breaks the package rules
|
|
2679
|
+
```
|
|
2680
|
+
|
|
2681
|
+
With the skill loaded, Claude follows its rules, avoids the mistakes in its "What not to do" list
|
|
2682
|
+
and, before using anything the skill does not show, reads this README in `node_modules`.
|
|
2683
|
+
|
|
2684
|
+
### Keeping it up to date
|
|
2685
|
+
|
|
2686
|
+
- The copy does not update itself: after updating `@archi-code/ddd`, run the command above again.
|
|
2687
|
+
- Commit `.claude/skills/archi-code-ddd/` to the repository so the whole team uses it.
|
|
2688
|
+
|
|
2689
|
+
## License
|
|
2690
|
+
|
|
2691
|
+
MIT
|
|
2692
|
+
|
|
2693
|
+
The in-memory event channel reimplements techniques of
|
|
2694
|
+
[emittery](https://github.com/sindresorhus/emittery), MIT License, Copyright (c) Sindre Sorhus.
|