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

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