tshex-cli 1.0.17 → 1.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.es.md +1449 -0
- package/README.md +1449 -0
- package/package.json +8 -10
- package/templates/lib/shared/application/{databases.ts → data-sources.ts} +1 -1
- package/templates/lib/shared/application/http.ts +28 -40
- package/readme.md +0 -217
package/README.es.md
ADDED
|
@@ -0,0 +1,1449 @@
|
|
|
1
|
+
# CLI de Arquitectura Hexagonal `tshex-cli`
|
|
2
|
+
|
|
3
|
+
`tshex-cli` crea la estructura base de una librería organizada por contextos. La estructura reúne contratos compartidos, conceptos de dominio, casos de uso y adaptadores en directorios con responsabilidades definidas.
|
|
4
|
+
|
|
5
|
+
En esta guía construiremos una librería llamada `core` con un contexto llamado `users`. El recorrido comienza con el CLI y continúa con la implementación de cada pieza generada.
|
|
6
|
+
|
|
7
|
+
Los ejemplos de esta guía son intencionalmente simples. Buscan mostrar la responsabilidad de cada pieza, no cubrir infraestructura real ni casos de producción.
|
|
8
|
+
|
|
9
|
+
## ¿Por qué?
|
|
10
|
+
|
|
11
|
+
La arquitectura hexagonal es un patrón de diseño de software que separa el dominio central de la aplicación de las dependencias externas. Esta separación se logra al dividir la aplicación en capas. Cada capa tiene una responsabilidad específica e interactúa con las demás de una forma concreta.
|
|
12
|
+
|
|
13
|
+
El objetivo es separar el código de dominio de las dependencias instaladas. Un framework de arquitectura hexagonal iría en contra de ese objetivo, porque acopla el código de dominio al propio framework. Por eso existe este CLI: genera por ti la estructura base, creando un código que posees y controlas, en lugar de un framework.
|
|
14
|
+
|
|
15
|
+
## Primeros pasos
|
|
16
|
+
|
|
17
|
+
### Instalación
|
|
18
|
+
|
|
19
|
+
Instala el paquete dentro de tu proyecto Node.js:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install tshex-cli
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
O instálalo de forma global:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g tshex-cli
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
El paquete registra el ejecutable `tshex`. Puedes invocarlo con `npx` desde el directorio del proyecto.
|
|
32
|
+
|
|
33
|
+
### Ayuda
|
|
34
|
+
|
|
35
|
+
Consulta los comandos y opciones disponibles con:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx tshex --help
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Crear una librería
|
|
42
|
+
|
|
43
|
+
La opción `--lib` recibe el nombre del directorio raíz de la librería:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx tshex --lib core
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
En este ejemplo, `core` será el espacio que contendrá el código compartido, los contextos y la implementación principal de la librería.
|
|
50
|
+
|
|
51
|
+
### Crear un contexto
|
|
52
|
+
|
|
53
|
+
La opción `--ctx` recibe el nombre del contexto:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx tshex --ctx users
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Un contexto agrupa las reglas y operaciones de una capacidad de la aplicación. `users`, `sales`, `billing` e `inventory` son ejemplos de contextos.
|
|
60
|
+
|
|
61
|
+
### Crear la librería y el primer contexto
|
|
62
|
+
|
|
63
|
+
Puedes generar ambas piezas en una sola ejecución:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npx tshex --lib core --ctx users
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
El comando crea esta estructura:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
core/
|
|
73
|
+
├── index.d.ts
|
|
74
|
+
├── main.ts
|
|
75
|
+
├── shared/
|
|
76
|
+
│ ├── application/
|
|
77
|
+
│ │ ├── data-sources.ts
|
|
78
|
+
│ │ ├── events.ts
|
|
79
|
+
│ │ ├── http.ts
|
|
80
|
+
│ │ ├── loggers.ts
|
|
81
|
+
│ │ ├── services.ts
|
|
82
|
+
│ │ └── validations.ts
|
|
83
|
+
│ └── domain/
|
|
84
|
+
│ ├── aggregates.ts
|
|
85
|
+
│ ├── entities.ts
|
|
86
|
+
│ ├── errors.ts
|
|
87
|
+
│ └── value-objects.ts
|
|
88
|
+
└── users/
|
|
89
|
+
├── adapters/
|
|
90
|
+
├── application/
|
|
91
|
+
├── domain/
|
|
92
|
+
├── example-ports.ts
|
|
93
|
+
└── index.ts
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Elegir el directorio de destino
|
|
97
|
+
|
|
98
|
+
La opción `--dir` indica el directorio desde el cual se crea la estructura:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npx tshex --dir ./src --lib core --ctx users
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
La librería del ejemplo queda ubicada en `src/core`.
|
|
105
|
+
|
|
106
|
+
Para agregar un contexto a una librería existente, utiliza la librería como directorio de destino:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
npx tshex --dir ./core --ctx billing
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
El contexto queda ubicado en `core/billing`.
|
|
113
|
+
|
|
114
|
+
### Crear un contexto para React
|
|
115
|
+
|
|
116
|
+
La opción `--react` crea un contexto con directorios orientados a una aplicación React:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npx tshex --ctx users --react
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
También puedes usar su forma corta:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
npx tshex --ctx users -R
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
La estructura del contexto contiene adaptadores para API, hooks y schemas, junto con aplicación, dominio y recursos de idioma:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
users/
|
|
132
|
+
├── adapters/
|
|
133
|
+
│ ├── api/
|
|
134
|
+
│ ├── hooks/
|
|
135
|
+
│ └── schemas/
|
|
136
|
+
├── application/
|
|
137
|
+
├── domain/
|
|
138
|
+
└── languages/
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Estructura de la librería
|
|
142
|
+
|
|
143
|
+
La librería se divide en una raíz, un directorio compartido y uno o más contextos. Cada nivel tiene una función dentro de la implementación.
|
|
144
|
+
|
|
145
|
+
### Raíz
|
|
146
|
+
|
|
147
|
+
La raíz contiene `index.d.ts` y `main.ts`.
|
|
148
|
+
|
|
149
|
+
`index.d.ts` declara tipos disponibles para la librería. `main.ts` contiene la implementación principal y las piezas públicas que pertenecen directamente a esa entrada.
|
|
150
|
+
|
|
151
|
+
### Código compartido
|
|
152
|
+
|
|
153
|
+
El directorio `shared` contiene código utilizado por varios contextos. Aquí se ubican abstracciones, interfaces, contratos, tipos base e implementaciones puntuales con significado común dentro de la librería.
|
|
154
|
+
|
|
155
|
+
```text
|
|
156
|
+
shared/
|
|
157
|
+
├── domain/
|
|
158
|
+
└── application/
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`shared/domain` contiene bases para modelar conceptos del negocio. `shared/application` contiene contratos para coordinar casos de uso, fuentes de datos, eventos, validaciones, respuestas HTTP y logs.
|
|
162
|
+
|
|
163
|
+
### Contextos
|
|
164
|
+
|
|
165
|
+
Un contexto representa una capacidad de la aplicación y agrupa su vocabulario, sus reglas y sus operaciones.
|
|
166
|
+
|
|
167
|
+
```text
|
|
168
|
+
users/
|
|
169
|
+
billing/
|
|
170
|
+
sales/
|
|
171
|
+
inventory/
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
La separación por contextos organiza una aplicación alrededor de sus capacidades. Varios contextos pueden formar parte del mismo proyecto, del mismo proceso y de la misma fuente de datos. Cada contexto conserva sus propias reglas mientras comparte las abstracciones generales de `shared`.
|
|
175
|
+
|
|
176
|
+
Cada contexto generado contiene tres directorios:
|
|
177
|
+
|
|
178
|
+
```text
|
|
179
|
+
domain/
|
|
180
|
+
application/
|
|
181
|
+
adapters/
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
#### `domain`
|
|
185
|
+
|
|
186
|
+
Contiene las capacidades del contexto. Una capacidad reúne conocimiento del negocio que puede utilizarse en distintos procesos: validar un correo, identificar una entidad, calcular un precio, cambiar el estado de una orden o agrupar las partes de una venta.
|
|
187
|
+
|
|
188
|
+
Los objetos de valor, las entidades y los agregados materializan estas capacidades mediante datos, reglas y comportamiento.
|
|
189
|
+
|
|
190
|
+
#### `application`
|
|
191
|
+
|
|
192
|
+
Contiene la aplicación de las capacidades del dominio en procesos que cumplen propósitos del sistema.
|
|
193
|
+
|
|
194
|
+
Un servicio de aplicación combina capacidades para completar una operación. Por ejemplo, el proceso de registrar un usuario puede validar el correo, construir la entidad, guardar sus datos, publicar un evento y registrar el resultado.
|
|
195
|
+
|
|
196
|
+
#### Raíz del contexto
|
|
197
|
+
|
|
198
|
+
La raíz contiene `index.ts` y otros archivos `.ts` destinados a los puertos del contexto.
|
|
199
|
+
|
|
200
|
+
Un puerto describe una forma de comunicación entre el contexto y otro sistema. Define los datos recibidos, los datos entregados y la operación disponible en esa frontera.
|
|
201
|
+
|
|
202
|
+
#### `adapters`
|
|
203
|
+
|
|
204
|
+
Contiene las integraciones que comunican el contexto con otros sistemas. Un adaptador importa un puerto, implementa la comunicación definida por ese puerto y conecta la entrada o salida externa con un proceso de aplicación.
|
|
205
|
+
|
|
206
|
+
Un adaptador puede integrar un controlador HTTP, un consumidor de mensajes, un SDK, un driver, un cliente remoto, un event bus o un proveedor de logs.
|
|
207
|
+
|
|
208
|
+
### Arquitectura por capas
|
|
209
|
+
|
|
210
|
+
La estructura se organiza en tres niveles: capacidades, procesos y comunicación.
|
|
211
|
+
|
|
212
|
+
```text
|
|
213
|
+
Sistema externo
|
|
214
|
+
↕
|
|
215
|
+
Puerto + adaptador
|
|
216
|
+
↓
|
|
217
|
+
Aplicación
|
|
218
|
+
↓
|
|
219
|
+
Dominio
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
El dominio ocupa la capa interior y reúne las capacidades del contexto.
|
|
223
|
+
|
|
224
|
+
La aplicación ocupa la capa intermedia y utiliza esas capacidades para construir procesos que cumplen propósitos del sistema.
|
|
225
|
+
|
|
226
|
+
Los puertos y adaptadores ocupan la capa exterior y resuelven la comunicación entre sistemas.
|
|
227
|
+
|
|
228
|
+
Un puerto declara la comunicación disponible en la frontera del contexto: qué datos entran, qué datos salen y qué operación se expone. Los puertos se declaran en `index.ts` o en módulos `.ts` ubicados en la raíz del contexto.
|
|
229
|
+
|
|
230
|
+
Un adaptador implementa esa comunicación. Importa el puerto correspondiente, traduce la entrada o salida externa al formato del proceso de aplicación y delega el trabajo al servicio de aplicación.
|
|
231
|
+
|
|
232
|
+
La dirección de imports sigue este recorrido:
|
|
233
|
+
|
|
234
|
+
```text
|
|
235
|
+
adapter → port
|
|
236
|
+
adapter → application
|
|
237
|
+
application → domain
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Por ejemplo, `users/index.ts` declara el puerto `CreateUserPort`. `users/adapters/create-user-adapter.ts` importa ese puerto y conecta una solicitud externa con `users/application/create-user-service.ts`. El servicio aplica las capacidades de `Email` y `User` para completar el registro.
|
|
241
|
+
|
|
242
|
+
> **Consejo:** empieza la implementación dentro del contexto y mueve una pieza a `shared` cuando su significado y su uso pertenecen a varios contextos.
|
|
243
|
+
|
|
244
|
+
## Tipos de la librería
|
|
245
|
+
|
|
246
|
+
### `index.d.ts`
|
|
247
|
+
|
|
248
|
+
El archivo declara el tipo genérico:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
type Generic<T = unknown> = Record<string, T>
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
`Generic<T>` representa un objeto con claves de tipo `string` y valores de un tipo común.
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
const filters: Generic<string> = {
|
|
258
|
+
status: 'active'
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Cuando el tipo se omite, los valores utilizan `unknown`:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
const metadata: Generic = {
|
|
266
|
+
retries: 2
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Los contratos de fuentes de datos utilizan esta forma para representar objetos planos cuya estructura concreta será definida por cada implementación.
|
|
271
|
+
|
|
272
|
+
## Dominio compartido
|
|
273
|
+
|
|
274
|
+
El dominio comienza con conceptos pequeños y avanza hacia estructuras que reúnen varias identidades. Comenzaremos con los objetos de valor, continuaremos con las entidades y terminaremos con los agregados.
|
|
275
|
+
|
|
276
|
+
### Objetos de valor
|
|
277
|
+
|
|
278
|
+
Un objeto de valor representa un concepto que posee reglas, semántica o comportamiento propios. Su identidad está determinada por su valor.
|
|
279
|
+
|
|
280
|
+
El archivo `shared/domain/value-objects.ts` genera la clase base `ValueObject<T>` y las implementaciones `Email` y `NullableBoolean`.
|
|
281
|
+
|
|
282
|
+
#### Crear un correo electrónico
|
|
283
|
+
|
|
284
|
+
`Email` convierte un texto en un concepto de dominio con validación y operaciones propias:
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
import { Email } from './core/shared/domain/value-objects.js'
|
|
288
|
+
|
|
289
|
+
const email = Email.from('alejandro@example.com')
|
|
290
|
+
|
|
291
|
+
email.value
|
|
292
|
+
email.domain
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
La creación se realiza con `Email.from()`. Este método ejecuta `Email.isValid()` antes de construir la instancia.
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
Email.isValid('alejandro@example.com')
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Toda creación pasa por la misma regla de validez:
|
|
302
|
+
|
|
303
|
+
```text
|
|
304
|
+
valor recibido
|
|
305
|
+
↓
|
|
306
|
+
isValid(value)
|
|
307
|
+
↓
|
|
308
|
+
from(value)
|
|
309
|
+
↓
|
|
310
|
+
instancia válida
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Cuando la validación falla, `from()` lanza `ValueError`.
|
|
314
|
+
|
|
315
|
+
> **Consejo:** utiliza un tipo nativo cuando expresa completamente el dato. Crea un objeto de valor cuando el concepto aporta reglas u operaciones propias. Un identificador textual puede representarse con `string`; un correo electrónico se beneficia de `Email` porque incorpora validación y comportamiento.
|
|
316
|
+
|
|
317
|
+
#### Representar un estado booleano nullable
|
|
318
|
+
|
|
319
|
+
`NullableBoolean` modela los valores `true`, `false` y `null`:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
import { NullableBoolean } from './core/shared/domain/value-objects.js'
|
|
323
|
+
|
|
324
|
+
const status = NullableBoolean.from(null)
|
|
325
|
+
|
|
326
|
+
status.value
|
|
327
|
+
status.isIndeterminate()
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
El método `isIndeterminate()` expresa una operación propia del concepto y permite consultar el estado `null` con una intención explícita.
|
|
331
|
+
|
|
332
|
+
#### Implementar un objeto de valor
|
|
333
|
+
|
|
334
|
+
Crearemos un porcentaje de descuento. El ejemplo solo tiene una regla y una operación para que el foco quede en la idea del objeto de valor.
|
|
335
|
+
|
|
336
|
+
**`sales/domain/discount-percentage.ts`**
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import { ValueError } from '../../shared/domain/errors.js'
|
|
340
|
+
import { ValueObject } from '../../shared/domain/value-objects.js'
|
|
341
|
+
|
|
342
|
+
export class DiscountPercentage extends ValueObject<number> {
|
|
343
|
+
public override readonly value: number
|
|
344
|
+
|
|
345
|
+
protected constructor(value: number) {
|
|
346
|
+
super()
|
|
347
|
+
this.value = value
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
public override equals(
|
|
351
|
+
other: DiscountPercentage | null | undefined
|
|
352
|
+
): boolean {
|
|
353
|
+
return other instanceof DiscountPercentage &&
|
|
354
|
+
this.value === other.value
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
public applyTo(amount: number): number {
|
|
358
|
+
return amount - amount * (this.value / 100)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
public static override isValid(value: unknown): boolean {
|
|
362
|
+
return typeof value === 'number' &&
|
|
363
|
+
Number.isFinite(value) &&
|
|
364
|
+
value >= 0 &&
|
|
365
|
+
value <= 100
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
public static from(value: number): DiscountPercentage {
|
|
369
|
+
if (this.isValid(value) === false) {
|
|
370
|
+
throw new ValueError(String(value), this.name)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return new this(value)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Ahora podemos crear y utilizar el concepto:
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
const discount = DiscountPercentage.from(15)
|
|
382
|
+
const finalPrice = discount.applyTo(100)
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
`isValid()` concentra la regla. `from()` crea la instancia válida. `applyTo()` agrega el comportamiento propio del concepto.
|
|
386
|
+
|
|
387
|
+
### Entidades
|
|
388
|
+
|
|
389
|
+
Una entidad representa un concepto con identidad propia. Dos instancias representan el mismo elemento cuando comparten esa identidad.
|
|
390
|
+
|
|
391
|
+
El archivo `shared/domain/entities.ts` genera la clase base `Entity`. Cada entidad implementa `equals()` y `toJSON()`.
|
|
392
|
+
|
|
393
|
+
Crearemos una entidad para el contexto `users`.
|
|
394
|
+
|
|
395
|
+
**`users/domain/user.ts`**
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
import { Entity } from '../../shared/domain/entities.js'
|
|
399
|
+
import {
|
|
400
|
+
Email,
|
|
401
|
+
NullableBoolean
|
|
402
|
+
} from '../../shared/domain/value-objects.js'
|
|
403
|
+
|
|
404
|
+
export class User extends Entity {
|
|
405
|
+
public constructor(
|
|
406
|
+
public readonly id: string,
|
|
407
|
+
public readonly email: Email,
|
|
408
|
+
public readonly active: NullableBoolean
|
|
409
|
+
) {
|
|
410
|
+
super()
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
public override equals(other: Entity): boolean {
|
|
414
|
+
return other instanceof User && other.id === this.id
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
public override toJSON(): Record<string, unknown> {
|
|
418
|
+
return {
|
|
419
|
+
id: this.id,
|
|
420
|
+
email: this.email.value,
|
|
421
|
+
active: this.active.value
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
La propiedad `id` define la identidad. El método `equals()` compara entidades a partir de esa propiedad.
|
|
428
|
+
|
|
429
|
+
`toJSON()` produce una representación plana de la entidad:
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
const user = new User(
|
|
433
|
+
'user-1',
|
|
434
|
+
Email.from('alejandro@example.com'),
|
|
435
|
+
NullableBoolean.from(true)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
const json = user.toJSON()
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
El resultado utiliza los valores internos de `Email` y `NullableBoolean`.
|
|
442
|
+
|
|
443
|
+
### Agregados
|
|
444
|
+
|
|
445
|
+
Un agregado reúne varias entidades en una unidad lógica. Las operaciones del agregado dependen de todas las identidades que lo componen.
|
|
446
|
+
|
|
447
|
+
Un equipo puede reunir a una persona líder y a varias integrantes:
|
|
448
|
+
|
|
449
|
+
```text
|
|
450
|
+
Team
|
|
451
|
+
├── leader
|
|
452
|
+
└── members
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Cada elemento conserva su propia identidad dentro de la unidad.
|
|
456
|
+
|
|
457
|
+
**`users/domain/team.ts`**
|
|
458
|
+
|
|
459
|
+
```ts
|
|
460
|
+
import { Aggregate } from '../../shared/domain/aggregates.js'
|
|
461
|
+
import type { User } from './user.js'
|
|
462
|
+
|
|
463
|
+
export class Team extends Aggregate {
|
|
464
|
+
public constructor(
|
|
465
|
+
public readonly leader: User,
|
|
466
|
+
public readonly members: User[]
|
|
467
|
+
) {
|
|
468
|
+
super()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
public size(): number {
|
|
472
|
+
return this.members.length + 1
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`Team` agrupa varias entidades `User` en una sola unidad lógica. El método `size()` opera sobre ese conjunto.
|
|
478
|
+
|
|
479
|
+
### Errores del dominio
|
|
480
|
+
|
|
481
|
+
El archivo `shared/domain/errors.ts` genera `ValueError`. Este error representa un valor recibido que incumple la regla esperada.
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
import { ValueError } from '../../shared/domain/errors.js'
|
|
485
|
+
|
|
486
|
+
if (quantity <= 0) {
|
|
487
|
+
throw new ValueError(String(quantity), 'PositiveQuantity')
|
|
488
|
+
}
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Al estar en `shared`, `ValueError` puede utilizarse desde cualquier contexto y desde cualquier concepto del dominio que valide valores.
|
|
492
|
+
|
|
493
|
+
## Aplicación compartida
|
|
494
|
+
|
|
495
|
+
La capa de aplicación coordina los casos de uso. Sus contratos conectan el dominio con validaciones, fuentes de datos, respuestas HTTP, logs y eventos.
|
|
496
|
+
|
|
497
|
+
### Validaciones
|
|
498
|
+
|
|
499
|
+
El archivo `shared/application/validations.ts` declara el contrato `Validatable`:
|
|
500
|
+
|
|
501
|
+
```ts
|
|
502
|
+
isValid(): boolean
|
|
503
|
+
validate(): unknown
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
`isValid()` consulta el estado de la validación. `validate()` ejecuta la validación y devuelve el resultado definido por la implementación.
|
|
507
|
+
|
|
508
|
+
Crearemos una validación para el caso de uso que registra usuarios.
|
|
509
|
+
|
|
510
|
+
**`users/application/create-user-validation.ts`**
|
|
511
|
+
|
|
512
|
+
```ts
|
|
513
|
+
import type { Validatable } from '../../shared/application/validations.js'
|
|
514
|
+
import { Email } from '../../shared/domain/value-objects.js'
|
|
515
|
+
|
|
516
|
+
export class CreateUserValidation implements Validatable {
|
|
517
|
+
public constructor(private readonly email: string) {}
|
|
518
|
+
|
|
519
|
+
public isValid(): boolean {
|
|
520
|
+
return Email.isValid(this.email)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
public validate(): string[] {
|
|
524
|
+
return this.isValid()
|
|
525
|
+
? []
|
|
526
|
+
: ['The email is invalid.']
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
El servicio de aplicación puede ejecutar esta validación antes de construir la entidad `User`.
|
|
532
|
+
|
|
533
|
+
### Servicios
|
|
534
|
+
|
|
535
|
+
Los servicios representan los casos de uso de la aplicación. Cada servicio aplica capacidades del dominio en un proceso que cumple un propósito, como crear un usuario, confirmar una orden o registrar un pago.
|
|
536
|
+
|
|
537
|
+
El archivo `shared/application/services.ts` genera la clase base `Service`. Una implementación concreta define sus entradas, sus dependencias y el método que ejecuta el proceso.
|
|
538
|
+
|
|
539
|
+
Comenzaremos con un servicio que crea un usuario.
|
|
540
|
+
|
|
541
|
+
**`users/application/create-user-service.ts`**
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
import { Service } from '../../shared/application/services.js'
|
|
545
|
+
import {
|
|
546
|
+
Email,
|
|
547
|
+
NullableBoolean
|
|
548
|
+
} from '../../shared/domain/value-objects.js'
|
|
549
|
+
import { User } from '../domain/user.js'
|
|
550
|
+
import { CreateUserValidation } from './create-user-validation.js'
|
|
551
|
+
|
|
552
|
+
export interface UserWriter {
|
|
553
|
+
save(user: User): Promise<void>
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export type CreateUserCommand = {
|
|
557
|
+
id: string
|
|
558
|
+
email: string
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export type CreatedUser = {
|
|
562
|
+
id: string
|
|
563
|
+
email: string
|
|
564
|
+
active: boolean | null
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export class CreateUserService extends Service {
|
|
568
|
+
public constructor(private readonly users: UserWriter) {
|
|
569
|
+
super()
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
public async execute(
|
|
573
|
+
command: CreateUserCommand
|
|
574
|
+
): Promise<CreatedUser> {
|
|
575
|
+
const validation = new CreateUserValidation(command.email)
|
|
576
|
+
const errors = validation.validate()
|
|
577
|
+
|
|
578
|
+
if (errors.length !== 0) {
|
|
579
|
+
throw new Error(errors.join(', '))
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const user = new User(
|
|
583
|
+
command.id,
|
|
584
|
+
Email.from(command.email),
|
|
585
|
+
NullableBoolean.from(true)
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
await this.users.save(user)
|
|
589
|
+
|
|
590
|
+
return {
|
|
591
|
+
id: user.id,
|
|
592
|
+
email: user.email.value,
|
|
593
|
+
active: user.active.value
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
El servicio recibe un comando, valida la entrada, construye la entidad y delega el guardado. `CreateUserCommand` expresa la entrada del proceso. `CreatedUser` expresa la salida.
|
|
600
|
+
|
|
601
|
+
### Respuestas HTTP
|
|
602
|
+
|
|
603
|
+
El archivo `shared/application/http.ts` genera `HttpResponseBody`, un cuerpo de respuesta diseñado para API REST.
|
|
604
|
+
|
|
605
|
+
La clase organiza la respuesta en tres propiedades:
|
|
606
|
+
|
|
607
|
+
```ts
|
|
608
|
+
new HttpResponseBody(data, errors, links)
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
`data` contiene los datos de la operación y acepta `null` cuando la respuesta carece de datos:
|
|
612
|
+
|
|
613
|
+
```ts
|
|
614
|
+
const body = new HttpResponseBody({ id: 'user-1' })
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
`errors` contiene una lista de mensajes:
|
|
618
|
+
|
|
619
|
+
```ts
|
|
620
|
+
const body = new HttpResponseBody(
|
|
621
|
+
null,
|
|
622
|
+
['The email is invalid.']
|
|
623
|
+
)
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
`links` contiene enlaces HATEOAS relacionados con el recurso y sus operaciones disponibles:
|
|
627
|
+
|
|
628
|
+
```ts
|
|
629
|
+
const body = new HttpResponseBody(
|
|
630
|
+
{ id: 'user-1' },
|
|
631
|
+
null,
|
|
632
|
+
{
|
|
633
|
+
self: new URL('https://api.example.com/users/user-1')
|
|
634
|
+
}
|
|
635
|
+
)
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
La salida plana de una entidad puede utilizarse como `data`:
|
|
639
|
+
|
|
640
|
+
```ts
|
|
641
|
+
const body = new HttpResponseBody(user.toJSON())
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
### Logs
|
|
645
|
+
|
|
646
|
+
El archivo `shared/application/loggers.ts` declara el contrato `Logger`. La aplicación utiliza esta abstracción para producir logs que un adaptador envía a servicios externos.
|
|
647
|
+
|
|
648
|
+
El contrato incluye los niveles `debug`, `info`, `warning`, `error` y `critical`, junto con las constantes numéricas `DEBUG`, `INFO`, `WARNING`, `ERROR` y `CRITICAL`.
|
|
649
|
+
|
|
650
|
+
Crearemos un adaptador sencillo que conecta el contrato con la consola.
|
|
651
|
+
|
|
652
|
+
**`users/adapters/console-logger-adapter.ts`**
|
|
653
|
+
|
|
654
|
+
```ts
|
|
655
|
+
import { Logger } from '../../shared/application/loggers.js'
|
|
656
|
+
|
|
657
|
+
export class ConsoleLoggerAdapter extends Logger {
|
|
658
|
+
public debug(data: unknown): void {
|
|
659
|
+
console.debug(data)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
public info(data: unknown): void {
|
|
663
|
+
console.info(data)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
public warning(data: unknown): void {
|
|
667
|
+
console.warn(data)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
public error(data: unknown): void {
|
|
671
|
+
console.error(data)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
public critical(data: unknown): void {
|
|
675
|
+
console.error(data)
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
El servicio recibe `Logger` como dependencia. El adaptador decide adónde enviar cada nivel.
|
|
681
|
+
|
|
682
|
+
### Eventos
|
|
683
|
+
|
|
684
|
+
El archivo `shared/application/events.ts` contiene los contratos que conectan la aplicación con un event bus.
|
|
685
|
+
|
|
686
|
+
El flujo comienza con un evento, continúa con el dispatcher y termina en uno o más handlers:
|
|
687
|
+
|
|
688
|
+
```text
|
|
689
|
+
Service
|
|
690
|
+
↓ crea
|
|
691
|
+
Event
|
|
692
|
+
↓ entrega a
|
|
693
|
+
EventDispatcher
|
|
694
|
+
↓ publica en
|
|
695
|
+
Event bus
|
|
696
|
+
↓ ejecuta
|
|
697
|
+
EventHandler
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
#### Evento
|
|
701
|
+
|
|
702
|
+
`Event` representa un hecho ocurrido en la aplicación. Contiene el momento del evento y sus detalles planos.
|
|
703
|
+
|
|
704
|
+
**`users/application/user-created.ts`**
|
|
705
|
+
|
|
706
|
+
```ts
|
|
707
|
+
import { Event } from '../../shared/application/events.js'
|
|
708
|
+
|
|
709
|
+
export class UserCreated extends Event {
|
|
710
|
+
public constructor(userId: string) {
|
|
711
|
+
super(Date.now(), { userId })
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
#### Handler
|
|
717
|
+
|
|
718
|
+
`EventHandler` representa una reacción al evento.
|
|
719
|
+
|
|
720
|
+
**`users/application/log-user-created.ts`**
|
|
721
|
+
|
|
722
|
+
```ts
|
|
723
|
+
import {
|
|
724
|
+
Event,
|
|
725
|
+
EventHandler
|
|
726
|
+
} from '../../shared/application/events.js'
|
|
727
|
+
import { Logger } from '../../shared/application/loggers.js'
|
|
728
|
+
|
|
729
|
+
export class LogUserCreated extends EventHandler {
|
|
730
|
+
public constructor(private readonly logger: Logger) {
|
|
731
|
+
super()
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
public async handle(event: Event): Promise<void> {
|
|
735
|
+
this.logger.info(event.details)
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
#### Dispatcher
|
|
741
|
+
|
|
742
|
+
`EventDispatcher` representa el contrato de interacción con el event bus. Su implementación concreta suscribe handlers, retira suscripciones y despacha eventos.
|
|
743
|
+
|
|
744
|
+
```ts
|
|
745
|
+
subscribe(key, handler)
|
|
746
|
+
unsubscribe(key, handler)
|
|
747
|
+
dispatch(event)
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
El servicio puede recibir `EventDispatcher` y publicar `UserCreated` al completar el caso de uso.
|
|
751
|
+
|
|
752
|
+
## Fuentes de datos
|
|
753
|
+
|
|
754
|
+
El archivo `shared/application/data-sources.ts` organiza el acceso a una fuente de datos en cuatro piezas: `DriverManager`, `DataManager`, `DatasetManager` y `Repository`.
|
|
755
|
+
|
|
756
|
+
El flujo completo se ve así:
|
|
757
|
+
|
|
758
|
+
```text
|
|
759
|
+
DriverManager
|
|
760
|
+
↓ conecta y habilita
|
|
761
|
+
DataManager o DatasetManager
|
|
762
|
+
↓ entrega datos planos a
|
|
763
|
+
Repository
|
|
764
|
+
↓ transforma
|
|
765
|
+
Objetos de dominio
|
|
766
|
+
↓ utiliza
|
|
767
|
+
Service
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
Comenzaremos en la conexión y avanzaremos hasta el caso de uso.
|
|
771
|
+
|
|
772
|
+
### Driver manager
|
|
773
|
+
|
|
774
|
+
`DriverManager` conecta y desconecta la fuente mediante un driver. Cuando la conexión está disponible, `connect()` devuelve un data manager habilitado.
|
|
775
|
+
|
|
776
|
+
```ts
|
|
777
|
+
connect(...args): Promise<DataManager>
|
|
778
|
+
disconnect(): Promise<unknown>
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
Crearemos un manager para una colección de personas en memoria. El ejemplo evita una base de datos para que el foco quede en la responsabilidad del manager.
|
|
782
|
+
|
|
783
|
+
**`users/adapters/people-driver-manager.ts`**
|
|
784
|
+
|
|
785
|
+
```ts
|
|
786
|
+
import { DriverManager } from '../../shared/application/data-sources.js'
|
|
787
|
+
import { PeopleDataManager } from './people-data-manager.js'
|
|
788
|
+
|
|
789
|
+
export type PersonRecord = {
|
|
790
|
+
id: string
|
|
791
|
+
name: string
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
export class PeopleDriverManager
|
|
795
|
+
extends DriverManager<PeopleDataManager> {
|
|
796
|
+
|
|
797
|
+
public constructor(private readonly records: PersonRecord[]) {
|
|
798
|
+
super()
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
public async connect(): Promise<PeopleDataManager> {
|
|
802
|
+
return new PeopleDataManager(this.records)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
public async disconnect(): Promise<void> {
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
La implementación concreta puede encapsular un driver de base de datos, un cliente HTTP, un sistema de archivos u otra fuente.
|
|
811
|
+
|
|
812
|
+
### Data manager
|
|
813
|
+
|
|
814
|
+
`DataManager` actúa sobre la fuente y trabaja con objetos planos y arrays. Su forma base define dos operaciones:
|
|
815
|
+
|
|
816
|
+
```ts
|
|
817
|
+
all(): Promise<Array<T>>
|
|
818
|
+
none(): Array<T>
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
`all()` obtiene los registros disponibles. `none()` crea una colección vacía tipada.
|
|
822
|
+
|
|
823
|
+
Primero definiremos la forma que utiliza la fuente:
|
|
824
|
+
|
|
825
|
+
```ts
|
|
826
|
+
type PersonRecord = {
|
|
827
|
+
id: string
|
|
828
|
+
name: string
|
|
829
|
+
}
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
Ahora implementaremos el data manager.
|
|
833
|
+
|
|
834
|
+
**`users/adapters/people-data-manager.ts`**
|
|
835
|
+
|
|
836
|
+
```ts
|
|
837
|
+
import { DataManager } from '../../shared/application/data-sources.js'
|
|
838
|
+
|
|
839
|
+
export type PersonRecord = {
|
|
840
|
+
id: string
|
|
841
|
+
name: string
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
export class PeopleDataManager
|
|
845
|
+
extends DataManager<PersonRecord> {
|
|
846
|
+
|
|
847
|
+
public constructor(private readonly records: PersonRecord[]) {
|
|
848
|
+
super()
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
public async all(): Promise<PersonRecord[]> {
|
|
852
|
+
return this.records
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
public none(): PersonRecord[] {
|
|
856
|
+
return []
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
El data manager refleja la estructura de la fuente. En este ejemplo solo entrega registros planos.
|
|
862
|
+
|
|
863
|
+
#### Operaciones de la fuente
|
|
864
|
+
|
|
865
|
+
El archivo también declara interfaces para ampliar las capacidades de un data manager:
|
|
866
|
+
|
|
867
|
+
| Interfaz | Operación |
|
|
868
|
+
| --- | --- |
|
|
869
|
+
| `Filterable` | Filtra registros. |
|
|
870
|
+
| `Sortable` | Ordena registros. |
|
|
871
|
+
| `Creatable` | Crea registros. |
|
|
872
|
+
| `Updatable` | Actualiza registros. |
|
|
873
|
+
| `Deletable` | Elimina registros. |
|
|
874
|
+
| `Aggregatable` | Ejecuta agregaciones. |
|
|
875
|
+
| `Relatable` | Selecciona o precarga relaciones. |
|
|
876
|
+
|
|
877
|
+
Un data manager puede implementar las interfaces que necesita su fuente:
|
|
878
|
+
|
|
879
|
+
```ts
|
|
880
|
+
import {
|
|
881
|
+
Creatable,
|
|
882
|
+
DataManager,
|
|
883
|
+
Filterable
|
|
884
|
+
} from '../../shared/application/data-sources.js'
|
|
885
|
+
|
|
886
|
+
export class PeopleDataManager
|
|
887
|
+
extends DataManager<PersonRecord>
|
|
888
|
+
implements
|
|
889
|
+
Filterable<Partial<PersonRecord>>,
|
|
890
|
+
Creatable<PersonRecord> {
|
|
891
|
+
|
|
892
|
+
public constructor(private readonly records: PersonRecord[]) {
|
|
893
|
+
super()
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
public async all(): Promise<PersonRecord[]> {
|
|
897
|
+
return this.records
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
public none(): PersonRecord[] {
|
|
901
|
+
return []
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
public async filter(
|
|
905
|
+
selector: Partial<PersonRecord>
|
|
906
|
+
): Promise<PersonRecord[]> {
|
|
907
|
+
return this.records.filter((record) =>
|
|
908
|
+
(selector.id === undefined || record.id === selector.id) &&
|
|
909
|
+
(selector.name === undefined || record.name === selector.name)
|
|
910
|
+
)
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
public async create(data: PersonRecord): Promise<void> {
|
|
914
|
+
this.records.push(data)
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
```
|
|
918
|
+
|
|
919
|
+
También puede declarar operaciones específicas para consultas de la fuente:
|
|
920
|
+
|
|
921
|
+
```ts
|
|
922
|
+
public async findByName(name: string): Promise<PersonRecord[]> {
|
|
923
|
+
return this.filter({ name })
|
|
924
|
+
}
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
La capa de aplicación decide cuándo ejecutar estas operaciones, combina sus resultados y captura los errores producidos por la fuente.
|
|
928
|
+
|
|
929
|
+
### Dataset manager
|
|
930
|
+
|
|
931
|
+
`DatasetManager` extiende `DataManager` con operaciones de conjuntos:
|
|
932
|
+
|
|
933
|
+
```ts
|
|
934
|
+
union()
|
|
935
|
+
intersection()
|
|
936
|
+
difference()
|
|
937
|
+
symmetric_difference()
|
|
938
|
+
complement()
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
Esta implementación resulta útil cuando una operación trabaja con uniones, intersecciones, diferencias y complementos entre colecciones de datos.
|
|
942
|
+
|
|
943
|
+
### Repository
|
|
944
|
+
|
|
945
|
+
`Repository` actúa como intermediario entre los datos planos y los objetos de dominio.
|
|
946
|
+
|
|
947
|
+
```text
|
|
948
|
+
PersonRecord
|
|
949
|
+
↓ transform
|
|
950
|
+
Person
|
|
951
|
+
|
|
952
|
+
Person
|
|
953
|
+
↓ toRecord
|
|
954
|
+
PersonRecord
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
En este ejemplo la fuente y el dominio comparten casi la misma forma para que el foco quede en el rol del repositorio: traducir entre datos planos y objetos de dominio.
|
|
958
|
+
|
|
959
|
+
**`users/domain/person.ts`**
|
|
960
|
+
|
|
961
|
+
```ts
|
|
962
|
+
import { Entity } from '../../shared/domain/entities.js'
|
|
963
|
+
|
|
964
|
+
export class Person extends Entity {
|
|
965
|
+
public constructor(
|
|
966
|
+
public readonly id: string,
|
|
967
|
+
public readonly name: string
|
|
968
|
+
) {
|
|
969
|
+
super()
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
public override equals(other: Entity): boolean {
|
|
973
|
+
return other instanceof Person && other.id === this.id
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
public override toJSON(): Record<string, unknown> {
|
|
977
|
+
return {
|
|
978
|
+
id: this.id,
|
|
979
|
+
name: this.name
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
Ahora implementaremos el repositorio.
|
|
986
|
+
|
|
987
|
+
**`users/adapters/people-repository.ts`**
|
|
988
|
+
|
|
989
|
+
```ts
|
|
990
|
+
import { Repository } from '../../shared/application/data-sources.js'
|
|
991
|
+
import type { PeopleReader } from '../application/list-people-service.js'
|
|
992
|
+
import { Person } from '../domain/person.js'
|
|
993
|
+
import type { PersonRecord } from './people-data-manager.js'
|
|
994
|
+
import { PeopleDriverManager } from './people-driver-manager.js'
|
|
995
|
+
|
|
996
|
+
export class PeopleRepository
|
|
997
|
+
extends Repository<PeopleDriverManager>
|
|
998
|
+
implements PeopleReader {
|
|
999
|
+
|
|
1000
|
+
protected transform<T = Person>(data: Generic): T {
|
|
1001
|
+
const record = data as PersonRecord
|
|
1002
|
+
|
|
1003
|
+
return new Person(record.id, record.name) as T
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
private toRecord(person: Person): PersonRecord {
|
|
1007
|
+
return {
|
|
1008
|
+
id: person.id,
|
|
1009
|
+
name: person.name
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
public async findAll(): Promise<Person[]> {
|
|
1014
|
+
const dataManager = await this.manager.connect()
|
|
1015
|
+
const records = await dataManager.all()
|
|
1016
|
+
|
|
1017
|
+
return records.map((record) =>
|
|
1018
|
+
this.transform<Person>(record)
|
|
1019
|
+
)
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
public async save(person: Person): Promise<void> {
|
|
1023
|
+
const dataManager = await this.manager.connect()
|
|
1024
|
+
|
|
1025
|
+
await dataManager.create(this.toRecord(person))
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
`transform()` convierte el registro en una entidad. `toRecord()` hace el camino inverso.
|
|
1031
|
+
|
|
1032
|
+
### Consultas y errores en la aplicación
|
|
1033
|
+
|
|
1034
|
+
Los servicios de aplicación coordinan las consultas y capturan los errores de las fuentes de datos. El proceso expresa las capacidades de colaboración que necesita mediante contratos propios de la aplicación. Los adaptadores materializan esos contratos.
|
|
1035
|
+
|
|
1036
|
+
**`users/application/list-people-service.ts`**
|
|
1037
|
+
|
|
1038
|
+
```ts
|
|
1039
|
+
import { Service } from '../../shared/application/services.js'
|
|
1040
|
+
import type { Person } from '../domain/person.js'
|
|
1041
|
+
|
|
1042
|
+
export interface PeopleReader {
|
|
1043
|
+
findAll(): Promise<Person[]>
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
export class ListPeopleService extends Service {
|
|
1047
|
+
public constructor(private readonly people: PeopleReader) {
|
|
1048
|
+
super()
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
public async execute(): Promise<Person[]> {
|
|
1052
|
+
try {
|
|
1053
|
+
return await this.people.findAll()
|
|
1054
|
+
} catch {
|
|
1055
|
+
throw new Error('Could not list people.')
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
`PeopleReader` expresa la colaboración que necesita el proceso. `PeopleRepository`, ubicado en `adapters`, implementa esa colaboración y transforma los datos de la fuente en entidades `Person`.
|
|
1062
|
+
|
|
1063
|
+
## Puertos del contexto
|
|
1064
|
+
|
|
1065
|
+
Los puertos describen la comunicación entre el contexto y otros sistemas. Cada puerto define la forma de una interacción en la frontera: datos de entrada, datos de salida y operación disponible.
|
|
1066
|
+
|
|
1067
|
+
El contexto genera `index.ts` como archivo principal de puertos y `example-ports.ts` como ejemplo de un archivo adicional. Los puertos son importados por los adaptadores que materializan esa comunicación.
|
|
1068
|
+
|
|
1069
|
+
### Puerto principal
|
|
1070
|
+
|
|
1071
|
+
Declararemos la comunicación para crear un usuario directamente en `users/index.ts`.
|
|
1072
|
+
|
|
1073
|
+
**`users/index.ts`**
|
|
1074
|
+
|
|
1075
|
+
```ts
|
|
1076
|
+
export type CreateUserRequest = {
|
|
1077
|
+
id: string
|
|
1078
|
+
email: string
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
export type CreateUserResponse = {
|
|
1082
|
+
id: string
|
|
1083
|
+
email: string
|
|
1084
|
+
active: boolean | null
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
export interface CreateUserPort {
|
|
1088
|
+
create(
|
|
1089
|
+
request: CreateUserRequest
|
|
1090
|
+
): Promise<CreateUserResponse>
|
|
1091
|
+
}
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1094
|
+
`CreateUserRequest` representa la información recibida desde otro sistema. `CreateUserResponse` representa la respuesta entregada. `CreateUserPort` define la operación disponible en la frontera del contexto.
|
|
1095
|
+
|
|
1096
|
+
### Adaptar el puerto al proceso de aplicación
|
|
1097
|
+
|
|
1098
|
+
El adaptador importa el puerto y el servicio. Su responsabilidad consiste en traducir la solicitud externa al comando de aplicación y transformar el resultado en la respuesta del puerto.
|
|
1099
|
+
|
|
1100
|
+
**`users/adapters/create-user-adapter.ts`**
|
|
1101
|
+
|
|
1102
|
+
```ts
|
|
1103
|
+
import type {
|
|
1104
|
+
CreateUserPort,
|
|
1105
|
+
CreateUserRequest,
|
|
1106
|
+
CreateUserResponse
|
|
1107
|
+
} from '../index.js'
|
|
1108
|
+
import {
|
|
1109
|
+
CreateUserService,
|
|
1110
|
+
type CreateUserCommand
|
|
1111
|
+
} from '../application/create-user-service.js'
|
|
1112
|
+
|
|
1113
|
+
export class CreateUserAdapter implements CreateUserPort {
|
|
1114
|
+
public constructor(
|
|
1115
|
+
private readonly service: CreateUserService
|
|
1116
|
+
) {}
|
|
1117
|
+
|
|
1118
|
+
public async create(
|
|
1119
|
+
request: CreateUserRequest
|
|
1120
|
+
): Promise<CreateUserResponse> {
|
|
1121
|
+
const command: CreateUserCommand = {
|
|
1122
|
+
id: request.id,
|
|
1123
|
+
email: request.email
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const result = await this.service.execute(command)
|
|
1127
|
+
|
|
1128
|
+
return {
|
|
1129
|
+
id: result.id,
|
|
1130
|
+
email: result.email,
|
|
1131
|
+
active: result.active
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
```
|
|
1136
|
+
|
|
1137
|
+
El puerto expresa la comunicación. El adaptador la implementa. El servicio ejecuta el proceso. El dominio aporta las capacidades utilizadas por ese proceso.
|
|
1138
|
+
|
|
1139
|
+
### Puertos adicionales
|
|
1140
|
+
|
|
1141
|
+
Un contexto puede organizar sus comunicaciones en varios archivos de la raíz. Cada archivo declara los puertos de un grupo de interacciones.
|
|
1142
|
+
|
|
1143
|
+
**`users/example-ports.ts`**
|
|
1144
|
+
|
|
1145
|
+
```ts
|
|
1146
|
+
export type FindUserRequest = {
|
|
1147
|
+
id: string
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
export type FindUserResponse = {
|
|
1151
|
+
id: string
|
|
1152
|
+
email: string
|
|
1153
|
+
} | null
|
|
1154
|
+
|
|
1155
|
+
export interface FindUserPort {
|
|
1156
|
+
find(
|
|
1157
|
+
request: FindUserRequest
|
|
1158
|
+
): Promise<FindUserResponse>
|
|
1159
|
+
}
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
El adaptador correspondiente importa el contrato desde el archivo donde está declarado:
|
|
1163
|
+
|
|
1164
|
+
```ts
|
|
1165
|
+
import type {
|
|
1166
|
+
FindUserPort,
|
|
1167
|
+
FindUserRequest,
|
|
1168
|
+
FindUserResponse
|
|
1169
|
+
} from '../example-ports.js'
|
|
1170
|
+
```
|
|
1171
|
+
|
|
1172
|
+
> **Consejo:** agrupa en un mismo archivo los puertos que forman una comunicación coherente. Usa archivos adicionales cuando el contexto crece y aparecen grupos de interacciones con responsabilidades propias.
|
|
1173
|
+
|
|
1174
|
+
## Implementar un contexto
|
|
1175
|
+
|
|
1176
|
+
Ahora recorreremos una implementación completa de `users` siguiendo el orden de las capas: capacidades, proceso y comunicación.
|
|
1177
|
+
|
|
1178
|
+
### 1. Modelar las capacidades del dominio
|
|
1179
|
+
|
|
1180
|
+
Comenzamos con los conceptos que poseen reglas y comportamiento. Para el correo utilizamos `Email`; para la identidad utilizamos `string`; para el usuario creamos una entidad.
|
|
1181
|
+
|
|
1182
|
+
**`users/domain/user.ts`**
|
|
1183
|
+
|
|
1184
|
+
```ts
|
|
1185
|
+
import { Entity } from '../../shared/domain/entities.js'
|
|
1186
|
+
import {
|
|
1187
|
+
Email,
|
|
1188
|
+
NullableBoolean
|
|
1189
|
+
} from '../../shared/domain/value-objects.js'
|
|
1190
|
+
|
|
1191
|
+
export class User extends Entity {
|
|
1192
|
+
public constructor(
|
|
1193
|
+
public readonly id: string,
|
|
1194
|
+
public readonly email: Email,
|
|
1195
|
+
public readonly active: NullableBoolean
|
|
1196
|
+
) {
|
|
1197
|
+
super()
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
public override equals(other: Entity): boolean {
|
|
1201
|
+
return other instanceof User && other.id === this.id
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
public override toJSON(): Record<string, unknown> {
|
|
1205
|
+
return {
|
|
1206
|
+
id: this.id,
|
|
1207
|
+
email: this.email.value,
|
|
1208
|
+
active: this.active.value
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
`Email` aporta la capacidad de validar y representar el correo. `User` aporta identidad y representación del usuario.
|
|
1215
|
+
|
|
1216
|
+
### 2. Implementar la validación del proceso
|
|
1217
|
+
|
|
1218
|
+
La validación prepara la entrada utilizada por el caso de uso.
|
|
1219
|
+
|
|
1220
|
+
**`users/application/create-user-validation.ts`**
|
|
1221
|
+
|
|
1222
|
+
```ts
|
|
1223
|
+
import type { Validatable } from '../../shared/application/validations.js'
|
|
1224
|
+
import { Email } from '../../shared/domain/value-objects.js'
|
|
1225
|
+
|
|
1226
|
+
export class CreateUserValidation implements Validatable {
|
|
1227
|
+
public constructor(private readonly email: string) {}
|
|
1228
|
+
|
|
1229
|
+
public isValid(): boolean {
|
|
1230
|
+
return Email.isValid(this.email)
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
public validate(): string[] {
|
|
1234
|
+
return this.isValid()
|
|
1235
|
+
? []
|
|
1236
|
+
: ['The email is invalid.']
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
### 3. Implementar el proceso de aplicación
|
|
1242
|
+
|
|
1243
|
+
El servicio recibe sus colaboradores como dependencias y aplica las capacidades del dominio para completar el registro.
|
|
1244
|
+
|
|
1245
|
+
**`users/application/create-user-service.ts`**
|
|
1246
|
+
|
|
1247
|
+
```ts
|
|
1248
|
+
import { Service } from '../../shared/application/services.js'
|
|
1249
|
+
import {
|
|
1250
|
+
Email,
|
|
1251
|
+
NullableBoolean
|
|
1252
|
+
} from '../../shared/domain/value-objects.js'
|
|
1253
|
+
import { User } from '../domain/user.js'
|
|
1254
|
+
import { CreateUserValidation } from './create-user-validation.js'
|
|
1255
|
+
|
|
1256
|
+
export interface UserWriter {
|
|
1257
|
+
save(user: User): Promise<void>
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
export type CreateUserCommand = {
|
|
1261
|
+
id: string
|
|
1262
|
+
email: string
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
export type CreatedUser = {
|
|
1266
|
+
id: string
|
|
1267
|
+
email: string
|
|
1268
|
+
active: boolean | null
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
export class CreateUserService extends Service {
|
|
1272
|
+
public constructor(private readonly users: UserWriter) {
|
|
1273
|
+
super()
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
public async execute(
|
|
1277
|
+
command: CreateUserCommand
|
|
1278
|
+
): Promise<CreatedUser> {
|
|
1279
|
+
const validation = new CreateUserValidation(command.email)
|
|
1280
|
+
const errors = validation.validate()
|
|
1281
|
+
|
|
1282
|
+
if (errors.length !== 0) {
|
|
1283
|
+
throw new Error(errors.join(', '))
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const user = new User(
|
|
1287
|
+
command.id,
|
|
1288
|
+
Email.from(command.email),
|
|
1289
|
+
NullableBoolean.from(true)
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
await this.users.save(user)
|
|
1293
|
+
|
|
1294
|
+
return {
|
|
1295
|
+
id: user.id,
|
|
1296
|
+
email: user.email.value,
|
|
1297
|
+
active: user.active.value
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
```
|
|
1302
|
+
|
|
1303
|
+
`UserWriter` declara la operación que el servicio necesita del adaptador. El flujo queda en cuatro pasos: validar, construir la entidad, guardar y responder.
|
|
1304
|
+
|
|
1305
|
+
### 4. Declarar el puerto de comunicación
|
|
1306
|
+
|
|
1307
|
+
El puerto define cómo otro sistema solicita la creación de un usuario.
|
|
1308
|
+
|
|
1309
|
+
**`users/index.ts`**
|
|
1310
|
+
|
|
1311
|
+
```ts
|
|
1312
|
+
export type CreateUserRequest = {
|
|
1313
|
+
id: string
|
|
1314
|
+
email: string
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
export type CreateUserResponse = {
|
|
1318
|
+
id: string
|
|
1319
|
+
email: string
|
|
1320
|
+
active: boolean | null
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
export interface CreateUserPort {
|
|
1324
|
+
create(
|
|
1325
|
+
request: CreateUserRequest
|
|
1326
|
+
): Promise<CreateUserResponse>
|
|
1327
|
+
}
|
|
1328
|
+
```
|
|
1329
|
+
|
|
1330
|
+
### 5. Implementar el adaptador
|
|
1331
|
+
|
|
1332
|
+
El adaptador importa el puerto, recibe la solicitud externa y ejecuta el proceso de aplicación.
|
|
1333
|
+
|
|
1334
|
+
**`users/adapters/create-user-adapter.ts`**
|
|
1335
|
+
|
|
1336
|
+
```ts
|
|
1337
|
+
import type {
|
|
1338
|
+
CreateUserPort,
|
|
1339
|
+
CreateUserRequest,
|
|
1340
|
+
CreateUserResponse
|
|
1341
|
+
} from '../index.js'
|
|
1342
|
+
import {
|
|
1343
|
+
CreateUserService,
|
|
1344
|
+
type CreateUserCommand
|
|
1345
|
+
} from '../application/create-user-service.js'
|
|
1346
|
+
|
|
1347
|
+
export class CreateUserAdapter implements CreateUserPort {
|
|
1348
|
+
public constructor(
|
|
1349
|
+
private readonly service: CreateUserService
|
|
1350
|
+
) {}
|
|
1351
|
+
|
|
1352
|
+
public async create(
|
|
1353
|
+
request: CreateUserRequest
|
|
1354
|
+
): Promise<CreateUserResponse> {
|
|
1355
|
+
const command: CreateUserCommand = {
|
|
1356
|
+
id: request.id,
|
|
1357
|
+
email: request.email
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const result = await this.service.execute(command)
|
|
1361
|
+
|
|
1362
|
+
return {
|
|
1363
|
+
id: result.id,
|
|
1364
|
+
email: result.email,
|
|
1365
|
+
active: result.active
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
```
|
|
1370
|
+
|
|
1371
|
+
La comunicación completa sigue este recorrido:
|
|
1372
|
+
|
|
1373
|
+
```text
|
|
1374
|
+
Solicitud externa
|
|
1375
|
+
↓
|
|
1376
|
+
CreateUserAdapter
|
|
1377
|
+
↓
|
|
1378
|
+
CreateUserService
|
|
1379
|
+
↓
|
|
1380
|
+
Validación + Email + User
|
|
1381
|
+
↓
|
|
1382
|
+
UserWriter
|
|
1383
|
+
↓
|
|
1384
|
+
Respuesta externa
|
|
1385
|
+
```
|
|
1386
|
+
|
|
1387
|
+
### 6. Componer la implementación en `main.ts`
|
|
1388
|
+
|
|
1389
|
+
`main.ts` reúne las implementaciones principales de la librería. La composición crea el servicio y lo entrega al adaptador.
|
|
1390
|
+
|
|
1391
|
+
**`core/main.ts`**
|
|
1392
|
+
|
|
1393
|
+
```ts
|
|
1394
|
+
import { CreateUserService } from './users/application/create-user-service.js'
|
|
1395
|
+
import { CreateUserAdapter } from './users/adapters/create-user-adapter.js'
|
|
1396
|
+
import { InMemoryUserRepository } from './users/adapters/in-memory-user-repository.js'
|
|
1397
|
+
|
|
1398
|
+
export class CoreApplication {
|
|
1399
|
+
public readonly users: CreateUserAdapter
|
|
1400
|
+
|
|
1401
|
+
public constructor() {
|
|
1402
|
+
const users = new InMemoryUserRepository()
|
|
1403
|
+
const createUserService = new CreateUserService(users)
|
|
1404
|
+
|
|
1405
|
+
this.users = new CreateUserAdapter(createUserService)
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
```
|
|
1409
|
+
|
|
1410
|
+
`CoreApplication` ofrece una composición mínima: un repositorio concreto, un servicio y un adaptador.
|
|
1411
|
+
|
|
1412
|
+
### 7. Utilizar la implementación
|
|
1413
|
+
|
|
1414
|
+
El consumidor utiliza la entrada principal de la librería y accede al adaptador preparado en `main.ts`.
|
|
1415
|
+
|
|
1416
|
+
```ts
|
|
1417
|
+
import { CoreApplication } from './core/main.js'
|
|
1418
|
+
|
|
1419
|
+
const core = new CoreApplication()
|
|
1420
|
+
|
|
1421
|
+
const result = await core.users.create({
|
|
1422
|
+
id: 'user-1',
|
|
1423
|
+
email: 'alejandro@example.com'
|
|
1424
|
+
})
|
|
1425
|
+
```
|
|
1426
|
+
|
|
1427
|
+
El adaptador recibe la solicitud, aplica el puerto, ejecuta el servicio y entrega la respuesta.
|
|
1428
|
+
|
|
1429
|
+
## Referencia de archivos generados
|
|
1430
|
+
|
|
1431
|
+
| Archivo | Propósito |
|
|
1432
|
+
| --- | --- |
|
|
1433
|
+
| `core/index.d.ts` | Declara `Generic<T>` para objetos planos. |
|
|
1434
|
+
| `core/main.ts` | Contiene la implementación principal de la librería. |
|
|
1435
|
+
| `shared/domain/value-objects.ts` | Declara `ValueObject<T>` e implementa `Email` y `NullableBoolean`. |
|
|
1436
|
+
| `shared/domain/entities.ts` | Declara la base `Entity`. |
|
|
1437
|
+
| `shared/domain/aggregates.ts` | Declara la base `Aggregate`. |
|
|
1438
|
+
| `shared/domain/errors.ts` | Implementa `ValueError`. |
|
|
1439
|
+
| `shared/application/validations.ts` | Declara `Validatable`. |
|
|
1440
|
+
| `shared/application/services.ts` | Declara `Service` como base de los casos de uso. |
|
|
1441
|
+
| `shared/application/http.ts` | Implementa `HttpResponseBody` para respuestas REST y enlaces HATEOAS. |
|
|
1442
|
+
| `shared/application/loggers.ts` | Declara niveles de log y el contrato `Logger`. |
|
|
1443
|
+
| `shared/application/events.ts` | Declara `Event`, `EventHandler` y `EventDispatcher`. |
|
|
1444
|
+
| `shared/application/data-sources.ts` | Declara operaciones de fuentes, managers y repositorios. |
|
|
1445
|
+
| `users/index.ts` | Declara los puertos principales de comunicación del contexto. |
|
|
1446
|
+
| `users/example-ports.ts` | Muestra un archivo adicional de puertos de comunicación. |
|
|
1447
|
+
| `users/domain/` | Contiene las capacidades del contexto. |
|
|
1448
|
+
| `users/application/` | Contiene procesos que aplican las capacidades del dominio para cumplir propósitos. |
|
|
1449
|
+
| `users/adapters/` | Contiene integraciones que importan puertos y comunican el contexto con otros sistemas. |
|