@archi-code/ddd 0.0.0-stage → 3.0.0-alpha.2

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