@ronaldjdevfs/forge 1.3.0-beta → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/forge/SKILL.md +57 -128
- package/skills/forge/command/forge.md +59 -14
- package/skills/forge/reference/adr.md +242 -0
- package/skills/forge/reference/anti-corruption-layer.md +340 -0
- package/skills/forge/reference/api-design.md +7 -0
- package/skills/forge/reference/api-versioning.md +354 -0
- package/skills/forge/reference/architectural-depth-checklist.md +311 -0
- package/skills/forge/reference/architecture-template.md +41 -0
- package/skills/forge/reference/assay.md +6 -0
- package/skills/forge/reference/bounded-contexts.md +311 -0
- package/skills/forge/reference/chain.md +6 -0
- package/skills/forge/reference/cohesion-checklist.md +256 -0
- package/skills/forge/reference/cqrs.md +286 -0
- package/skills/forge/reference/data-patterns.md +6 -0
- package/skills/forge/reference/di-strategies.md +6 -0
- package/skills/forge/reference/errors.md +5 -0
- package/skills/forge/reference/events.md +8 -0
- package/skills/forge/reference/evolutionary-architecture.md +300 -0
- package/skills/forge/reference/forge.md +7 -0
- package/skills/forge/reference/hooks.md +6 -0
- package/skills/forge/reference/idempotency.md +283 -0
- package/skills/forge/reference/inscribe.md +5 -0
- package/skills/forge/reference/inspect.md +6 -0
- package/skills/forge/reference/modular-monolith.md +252 -0
- package/skills/forge/reference/observability.md +5 -0
- package/skills/forge/reference/quench.md +5 -0
- package/skills/forge/reference/relocate.md +6 -0
- package/skills/forge/reference/sagas.md +359 -0
- package/skills/forge/reference/security-patterns.md +6 -0
- package/skills/forge/reference/smelt.md +6 -0
- package/skills/forge/reference/temper.md +6 -0
- package/skills/forge/reference/testing-patterns.md +6 -0
- package/skills/forge/reference/transactional-outbox.md +311 -0
- package/skills/forge/scripts/architecture.mjs +10 -5
- package/skills/forge/scripts/assay.mjs +2 -2
- package/skills/forge/scripts/chain.mjs +31 -5
- package/skills/forge/scripts/context.mjs +24 -4
- package/skills/forge/scripts/detect.mjs +39 -30
- package/skills/forge/scripts/forge-boot.mjs +108 -0
- package/skills/forge/scripts/forge-config.mjs +182 -3
- package/skills/forge/scripts/forge-state.mjs +1 -1
- package/skills/forge/scripts/forgeSentinel-lib.mjs +2 -2
- package/skills/forge/scripts/forgeSentinel.mjs +2 -2
- package/skills/forge/scripts/forgeSmith.mjs +2 -2
- package/skills/forge/scripts/graph.mjs +65 -9
- package/skills/forge/scripts/hook.mjs +2 -2
- package/skills/forge/scripts/inspect.mjs +56 -48
- package/skills/forge/scripts/parse-imports.mjs +0 -2
- package/skills/forge/scripts/posttool.mjs +211 -17
- package/skills/forge/scripts/recommendation-engine.mjs +125 -0
- package/skills/forge/scripts/rollback.mjs +5 -3
- package/skills/forge/templates/feature/acl-gateway.ts.md +52 -0
- package/skills/forge/templates/feature/acl-repository.ts.md +36 -0
- package/skills/forge/templates/feature/acl-translator.ts.md +24 -0
- package/skills/forge/templates/feature/cqrs-query.ts.md +40 -0
- package/skills/forge/templates/feature/outbox-repository.ts.md +36 -0
- package/skills/forge/templates/feature/saga-orchestrator.ts.md +72 -0
- package/skills/forge/templates/platform/outbox-relayer.ts.md +80 -0
- package/skills/forge/tests/core.test.mjs +288 -4
- package/src/cli.js +26 -13
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# Anti-Corruption Layer (ACL)
|
|
2
|
+
|
|
3
|
+
Cuando un bounded context debe integrarse con otro que tiene un modelo distinto (especialmente legacy o externo), la ACL es la capa que traduce sin contaminar.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Definición y Propósito
|
|
8
|
+
|
|
9
|
+
Una **Anti-Corruption Layer** es un mecanismo de traducción entre dos bounded contexts. Su función es evitar que el modelo de un sistema externo (o legacy) "corrompa" el modelo de dominio del sistema nuevo.
|
|
10
|
+
|
|
11
|
+
```mermaid
|
|
12
|
+
flowchart LR
|
|
13
|
+
subgraph "Sistema Legacy"
|
|
14
|
+
LegacyModel[(BD Legacy)]
|
|
15
|
+
Customer[CustomerLegacy]
|
|
16
|
+
end
|
|
17
|
+
subgraph "ACL"
|
|
18
|
+
Translator[Traductor]
|
|
19
|
+
Gateway[Gateway]
|
|
20
|
+
end
|
|
21
|
+
subgraph "Sistema Nuevo (Forge)"
|
|
22
|
+
DomainModel[(BD Nueva)]
|
|
23
|
+
Service[Orders Service]
|
|
24
|
+
end
|
|
25
|
+
Customer --> Gateway
|
|
26
|
+
Gateway --> Translator
|
|
27
|
+
Translator --> Service
|
|
28
|
+
Service --> DomainModel
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Cuándo usar ACL:**
|
|
32
|
+
- Integración con un sistema legacy que no puede modificarse (SAP, Salesforce, mainframe)
|
|
33
|
+
- Integración con un servicio externo cuyo modelo no controlas (API de terceros)
|
|
34
|
+
- Migración incremental donde ambos sistemas coexisten (Strangler Fig)
|
|
35
|
+
- Dos bounded contexts que evolucionan con ritmos distintos pero necesitan comunicarse
|
|
36
|
+
|
|
37
|
+
**Cuándo NO usar ACL:**
|
|
38
|
+
- El sistema externo expone un Published Language estable (usa el contrato directamente)
|
|
39
|
+
- El modelo externo es idéntico o trivialmente mapeable (un mapper simple basta)
|
|
40
|
+
- La integración es temporal y el sistema externo será reemplazado pronto
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Estructura de una ACL
|
|
45
|
+
|
|
46
|
+
La ACL vive en `adapter/out/` de la feature que necesita protegerse:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
src/features/orders/
|
|
50
|
+
domain/
|
|
51
|
+
entities/
|
|
52
|
+
OrderEntity.ts
|
|
53
|
+
CustomerEntity.ts ← modelo limpio
|
|
54
|
+
repositories/
|
|
55
|
+
ICustomerRepository.ts ← puerto que la ACL implementa
|
|
56
|
+
adapters/
|
|
57
|
+
out/
|
|
58
|
+
legacy-crm/
|
|
59
|
+
CustomerGateway.ts ← llama al sistema legacy
|
|
60
|
+
CustomerTranslator.ts ← traduce del modelo legacy al dominio
|
|
61
|
+
CustomerACL.ts ← facade que combina gateway + translator
|
|
62
|
+
persistence/
|
|
63
|
+
PostgresCustomerRepository.ts ← implementación normal
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Componentes de la ACL
|
|
67
|
+
|
|
68
|
+
| Componente | Responsabilidad |
|
|
69
|
+
|---|---|
|
|
70
|
+
| **Gateway** | Comunicación con el sistema externo (HTTP, SOAP, archivos, BD) |
|
|
71
|
+
| **Translator** | Mapeo del modelo externo al modelo de dominio y viceversa |
|
|
72
|
+
| **Facade** | Orquesta gateway + translator. Implementa la interfaz del puerto. |
|
|
73
|
+
| **Contract** | DTOs del modelo externo (viven en la ACL, no contaminan el dominio) |
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Implementación
|
|
78
|
+
|
|
79
|
+
### 1. Definir el puerto (dominio)
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// src/features/orders/domain/repositories/ICustomerRepository.ts
|
|
83
|
+
export interface ICustomerRepository {
|
|
84
|
+
findById(id: string): Promise<CustomerEntity | null>;
|
|
85
|
+
findByEmail(email: string): Promise<CustomerEntity | null>;
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 2. Gateway (comunicación con externo)
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// src/features/orders/adapters/out/legacy-crm/CustomerGateway.ts
|
|
93
|
+
// Este archivo es infra y puede depender de HTTP clients, SOAP, etc.
|
|
94
|
+
|
|
95
|
+
import { LegacyCustomerDTO } from "./LegacyCustomerDTO";
|
|
96
|
+
|
|
97
|
+
export class LegacyCRMGateway {
|
|
98
|
+
private readonly baseUrl: string;
|
|
99
|
+
|
|
100
|
+
constructor(baseUrl: string) {
|
|
101
|
+
this.baseUrl = baseUrl;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async fetchCustomer(crmId: string): Promise<LegacyCustomerDTO> {
|
|
105
|
+
const response = await fetch(`${this.baseUrl}/api/customers/${crmId}`, {
|
|
106
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
107
|
+
});
|
|
108
|
+
if (!response.ok) throw new Error(`CRM error: ${response.statusText}`);
|
|
109
|
+
return response.json();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 3. Translator (traducción)
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
// src/features/orders/adapters/out/legacy-crm/CustomerTranslator.ts
|
|
118
|
+
// Este archivo es PURO. No depende de infraestructura. Solo transforma datos.
|
|
119
|
+
|
|
120
|
+
import { LegacyCustomerDTO } from "./LegacyCustomerDTO";
|
|
121
|
+
import { CustomerEntity } from "../../../domain/entities/CustomerEntity";
|
|
122
|
+
|
|
123
|
+
export class CustomerTranslator {
|
|
124
|
+
toDomain(dto: LegacyCustomerDTO): CustomerEntity {
|
|
125
|
+
return new CustomerEntity({
|
|
126
|
+
id: dto.customerId,
|
|
127
|
+
name: `${dto.firstName} ${dto.lastName}`,
|
|
128
|
+
email: dto.emailAddress,
|
|
129
|
+
status: this.mapStatus(dto.active),
|
|
130
|
+
createdAt: new Date(dto.registrationDate),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private mapStatus(legacyActive: boolean): "active" | "inactive" {
|
|
135
|
+
return legacyActive ? "active" : "inactive";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### 4. Facade ACL (implementación del puerto)
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
// src/features/orders/adapters/out/legacy-crm/CustomerACL.ts
|
|
144
|
+
// Implementa ICustomerRepository traduciendo desde el CRM legacy.
|
|
145
|
+
|
|
146
|
+
import { ICustomerRepository } from "../../../domain/repositories/ICustomerRepository";
|
|
147
|
+
import { CustomerEntity } from "../../../domain/entities/CustomerEntity";
|
|
148
|
+
import { LegacyCRMGateway } from "./CustomerGateway";
|
|
149
|
+
import { CustomerTranslator } from "./CustomerTranslator";
|
|
150
|
+
|
|
151
|
+
export class LegacyCRMRepository implements ICustomerRepository {
|
|
152
|
+
constructor(
|
|
153
|
+
private readonly gateway: LegacyCRMGateway,
|
|
154
|
+
private readonly translator: CustomerTranslator,
|
|
155
|
+
) {}
|
|
156
|
+
|
|
157
|
+
async findById(id: string): Promise<CustomerEntity | null> {
|
|
158
|
+
try {
|
|
159
|
+
const dto = await this.gateway.fetchCustomer(id);
|
|
160
|
+
return this.translator.toDomain(dto);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// El gateway lanza errores técnicos. La ACL traduce a errores de dominio.
|
|
163
|
+
if (error.message.includes("404")) return null;
|
|
164
|
+
throw new Error("CustomerRepository.fetchFailed");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async findByEmail(email: string): Promise<CustomerEntity | null> {
|
|
169
|
+
// Si el CRM legacy no soporta búsqueda por email, la ACL puede:
|
|
170
|
+
// 1. Obtener todos los clientes y filtrar (ineficiente)
|
|
171
|
+
// 2. Cachear localmente y buscar en caché
|
|
172
|
+
// 3. Fallar con error explícito
|
|
173
|
+
throw new Error("CustomerRepository.methodNotSupported");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### 5. Registro en DI
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// En la composición root (platform/di/container.ts)
|
|
182
|
+
container.registerSingleton<ICustomerRepository>("ICustomerRepository", {
|
|
183
|
+
useClass: process.env.USE_LEGACY_CRM === "true"
|
|
184
|
+
? LegacyCRMRepository // ACL
|
|
185
|
+
: PostgresCustomerRepository, // implementación nativa
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
La DI permite switchear entre legacy y nativo sin cambiar el caso de uso.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Strangler Fig Pattern
|
|
194
|
+
|
|
195
|
+
El Strangler Fig permite migrar de un sistema legacy a uno nuevo incrementalmente, usando ACL como fachada.
|
|
196
|
+
|
|
197
|
+
### Fase 1: ACL intercepta todo
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
[API Gateway] → [ACL] → [Legacy CRM]
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Todas las llamadas pasan por la ACL que habla con el legacy.
|
|
204
|
+
|
|
205
|
+
### Fase 2: Rutas individuales migradas
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
// La ACL decide por ruta/característica si va al legacy o al nuevo
|
|
209
|
+
export class CustomerRouterACL implements ICustomerRepository {
|
|
210
|
+
constructor(
|
|
211
|
+
private readonly legacy: LegacyCRMRepository,
|
|
212
|
+
private readonly newRepo: PostgresCustomerRepository,
|
|
213
|
+
private readonly migrationFlag: FeatureFlagService,
|
|
214
|
+
) {}
|
|
215
|
+
|
|
216
|
+
async findById(id: string): Promise<CustomerEntity | null> {
|
|
217
|
+
if (this.migrationFlag.isEnabled("customer-read")) {
|
|
218
|
+
return await this.newRepo.findById(id);
|
|
219
|
+
}
|
|
220
|
+
return await this.legacy.findById(id);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Fase 3: Legacy retirado
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
[API Gateway] → [PostgresCustomerRepository]
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
La ACL ya no es necesaria. Se elimina el gateway, el translator y la facade.
|
|
232
|
+
|
|
233
|
+
### Reglas para Strangler Fig exitoso
|
|
234
|
+
|
|
235
|
+
1. **Un feature completo se migra a la vez**, no funciones sueltas
|
|
236
|
+
2. **La ACL nunca modifica el legacy**. Solo lee y traduce
|
|
237
|
+
3. **Feature flags** para activar/desactivar rutas migradas
|
|
238
|
+
4. **Métricas de comparación**: response time, error rate, data consistency entre legacy y nuevo
|
|
239
|
+
5. **Rollback plan**: desactivar el feature flag vuelve al legacy instantáneamente
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Conexión con el Modelo de Forge
|
|
244
|
+
|
|
245
|
+
### Reglas de dependencia y ACL
|
|
246
|
+
|
|
247
|
+
| Regla | Relación con ACL |
|
|
248
|
+
|---|---|
|
|
249
|
+
| **R1** (feature → infra) | La ACL está en `adapter/out/` y accede a infra (el gateway). Está permitido porque el adapter es el puerto de salida. El dominio nunca ve infra. |
|
|
250
|
+
| **R7** (infra → feature) | El gateway externo llama a la ACL (infra → adapter). No viola R7 porque el adapter recibe la llamada y la traduce al dominio. |
|
|
251
|
+
| **R8** (cross-feature) | Si la ACL se comunica con otra feature de Forge, debe hacerlo mediante eventos o contratos en shared/, no con imports directos. |
|
|
252
|
+
|
|
253
|
+
### La ACL en la arquitectura de 4 capas
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
┌─────────────────────────────────────────────────┐
|
|
257
|
+
│ features/orders/ │
|
|
258
|
+
│ domain/ ← modelo protegido │
|
|
259
|
+
│ CustomerEntity │
|
|
260
|
+
│ ICustomerRepository ← puerto │
|
|
261
|
+
│ adapters/out/ │
|
|
262
|
+
│ legacy-crm/ ← ACL completa │
|
|
263
|
+
│ CustomerGateway ← infra (llama externo) │
|
|
264
|
+
│ CustomerTranslator ← pure translation │
|
|
265
|
+
│ CustomerACL ← facade │
|
|
266
|
+
│ persistence/ │
|
|
267
|
+
│ PostgresCustomerRepository │
|
|
268
|
+
└─────────────────────────────────────────────────┘
|
|
269
|
+
│
|
|
270
|
+
▼ (gateway)
|
|
271
|
+
┌──────────────────────┐
|
|
272
|
+
│ CRM Legacy (externo) │
|
|
273
|
+
│ /api/customers/:id │
|
|
274
|
+
└──────────────────────┘
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Dónde NO poner la ACL
|
|
278
|
+
|
|
279
|
+
| Ubicación incorrecta | Problema |
|
|
280
|
+
|---|---|
|
|
281
|
+
| `src/shared/acl/` | La ACL es específica de un feature, no compartida. Cada feature tiene su propia ACL. |
|
|
282
|
+
| `src/infra/` | La ACL no es infraestructura genérica. Pertenece al adapter del feature. |
|
|
283
|
+
| `src/platform/` | Platform no debe saber qué sistemas externos existen. |
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Anti-patrones
|
|
288
|
+
|
|
289
|
+
| Anti-patrón | Problema | Solución |
|
|
290
|
+
|---|---|---|
|
|
291
|
+
| **Leaky Abstraction** | La ACL pasa DTOs del sistema externo en vez de traducir. El dominio termina dependiendo del modelo legacy. | El Translator debe mapear TODOS los campos. Si falta uno, el dominio no sabe que existe. |
|
|
292
|
+
| **Pasamanos (passthrough)** | La ACL no traduce nada. Solo delega. Es una capa que añade complejidad sin valor. | Si no hay traducción que hacer, no uses ACL. Usa el contrato directamente. |
|
|
293
|
+
| **ACL que escribe en el legacy** | La ACL muta el sistema externo para "facilitar" la integración. | La ACL solo traduce. Si necesitas escribir, el legacy debe exponer API de escritura. |
|
|
294
|
+
| **ACL compartida** | Una sola ACL para todos los features. | Cada feature tiene su propia ACL adaptada a su modelo de dominio. |
|
|
295
|
+
| **Gateway sin timeout** | Una llamada al legacy lenta bloquea todo el feature. | Timeouts, circuit breakers y fallbacks obligatorios en el gateway. |
|
|
296
|
+
| **ACL sin test** | No se puede verificar que la traducción es correcta. | Tests unitarios del Translator + tests de integración del Gateway. |
|
|
297
|
+
|
|
298
|
+
### Testing de ACL
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
// Test unitario del Translator (puro, sin infraestructura)
|
|
302
|
+
describe("CustomerTranslator", () => {
|
|
303
|
+
it("traduce LegacyCustomerDTO a CustomerEntity", () => {
|
|
304
|
+
const dto: LegacyCustomerDTO = {
|
|
305
|
+
customerId: "123",
|
|
306
|
+
firstName: "John",
|
|
307
|
+
lastName: "Doe",
|
|
308
|
+
emailAddress: "john@example.com",
|
|
309
|
+
active: true,
|
|
310
|
+
registrationDate: "2024-01-15T10:00:00Z",
|
|
311
|
+
};
|
|
312
|
+
const result = new CustomerTranslator().toDomain(dto);
|
|
313
|
+
expect(result.id).toBe("123");
|
|
314
|
+
expect(result.status).toBe("active");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("mapea active=false a status=inactive", () => {
|
|
318
|
+
const dto = { ...baseDTO, active: false };
|
|
319
|
+
const result = new CustomerTranslator().toDomain(dto);
|
|
320
|
+
expect(result.status).toBe("inactive");
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## Conexión con Forge
|
|
328
|
+
|
|
329
|
+
| Comando | Acción |
|
|
330
|
+
|---|---|
|
|
331
|
+
| `forge cast` | Durante el shape de la feature, detectar si necesita ACL |
|
|
332
|
+
| `forge inspect` | Detecta patrones que indican necesidad de ACL (imports directos a infra externa) |
|
|
333
|
+
| `forge relocate` | Puede generar ACL automática al migrar un feature desde legacy |
|
|
334
|
+
| `forge reforge` | Sugiere introducir ACL cuando detecta leaky abstractions |
|
|
335
|
+
|
|
336
|
+
## Ver también
|
|
337
|
+
|
|
338
|
+
- `reference/bounded-contexts.md` — contexts que la ACL aísla
|
|
339
|
+
- `reference/modular-monolith.md` — migración con ACL entre módulos
|
|
340
|
+
- `reference/relocate.md` — uso de ACL durante migración legacy
|
|
@@ -60,3 +60,10 @@ export interface IPaginatedResponse<T> {
|
|
|
60
60
|
- Errores normalizados: `{ error: { code, message, details? } }`
|
|
61
61
|
- Idempotencia en POST con `Idempotency-Key` header
|
|
62
62
|
- HATEOAS opcional, solo para APIs hipermedia
|
|
63
|
+
|
|
64
|
+
## Ver también
|
|
65
|
+
|
|
66
|
+
- `reference/api-versioning.md` — versionado de APIs y deprecación
|
|
67
|
+
- `reference/idempotency.md` — idempotency keys en POST
|
|
68
|
+
- `reference/security-patterns.md` — AuthN/AuthZ, rate limiting, validación
|
|
69
|
+
- `reference/testing-patterns.md` — tests de controllers y endpoints
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# API Versioning — Evolución de Contratos
|
|
2
|
+
|
|
3
|
+
Las APIs evolucionan. Los clientes no siempre pueden actualizarse al mismo ritmo. El versionado de APIs permite cambiar contratos sin romper clientes existentes.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Estrategias de Versionado
|
|
8
|
+
|
|
9
|
+
### URL Path (más común)
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
GET /api/v1/orders
|
|
13
|
+
GET /api/v2/orders
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// features/orders/adapters/in/http/v1/OrderController.ts
|
|
18
|
+
@Router("/api/v1/orders")
|
|
19
|
+
export class OrderV1Controller {
|
|
20
|
+
@Get("/:id")
|
|
21
|
+
async getById(@Param("id") id: string) {
|
|
22
|
+
return this.useCase.execute(new GetOrderQuery(id));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// features/orders/adapters/in/http/v2/OrderController.ts
|
|
27
|
+
@Router("/api/v2/orders")
|
|
28
|
+
export class OrderV2Controller {
|
|
29
|
+
@Get("/:id")
|
|
30
|
+
async getById(@Param("id") id: string) {
|
|
31
|
+
const result = await this.useCase.execute(new GetOrderQuery(id));
|
|
32
|
+
// v2 devuelve datos adicionales que v1 no tenía
|
|
33
|
+
return { ...result, estimatedDelivery: result.shippingEstimate };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Pros:** explícito, fácil de rutear, fácil de cachear
|
|
39
|
+
**Contras:** URLs feas, versionado de toda la API (no granular)
|
|
40
|
+
|
|
41
|
+
### Header (Accept / Content-Type)
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
GET /api/orders
|
|
45
|
+
Accept: application/vnd.company.v1+json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// Middleware que selecciona versión según Accept header
|
|
50
|
+
export class VersionResolver {
|
|
51
|
+
resolve(req: Request): string {
|
|
52
|
+
const accept = req.headers["accept"] || "";
|
|
53
|
+
const match = accept.match(/application\/vnd\.company\.v(\d+)\+json/);
|
|
54
|
+
return match ? `v${match[1]}` : "v1"; // default v1
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Pros:** URLs limpias, versionado granular por endpoint
|
|
60
|
+
**Contras:** complejidad en el cliente, difícil de debuggear, caché HTTP limitada
|
|
61
|
+
|
|
62
|
+
### Query Parameter
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
GET /api/orders?version=2
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Pros:** simple, fácil de testear
|
|
69
|
+
**Contras:** contamina la semántica del query param, fácil de olvidar
|
|
70
|
+
|
|
71
|
+
### Content Negotiation (recurso vs representación)
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
GET /api/orders/123
|
|
75
|
+
Accept: application/json;version=2
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Pros:** semánticamente correcto (negociación de contenido)
|
|
79
|
+
**Contras:** complejidad, poco soporte tooling
|
|
80
|
+
|
|
81
|
+
### Recomendación de Forge
|
|
82
|
+
|
|
83
|
+
| Contexto | Estrategia |
|
|
84
|
+
|---|---|
|
|
85
|
+
| API pública (terceros) | URL path (`/api/v1/`) — la más explícita |
|
|
86
|
+
| API interna (entre features) | Header Accept — URLs limpias para el monolith |
|
|
87
|
+
| API móvil | URL path — los clientes móviles cachean URLs |
|
|
88
|
+
| BFF (Backend for Frontend) | Sin versionado (se despliega junto al frontend) |
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Compatibilidad
|
|
93
|
+
|
|
94
|
+
### Cambios backward-compatible
|
|
95
|
+
|
|
96
|
+
| Cambio | Ejemplo |
|
|
97
|
+
|---|---|
|
|
98
|
+
| Añadir campo opcional en response | `{ name, email }` → `{ name, email, phone? }` |
|
|
99
|
+
| Añadir endpoint nuevo | `GET /v1/orders/:id/items` |
|
|
100
|
+
| Añadir campo en request con default | `{ name }` → `{ name, locale?: "en" }` |
|
|
101
|
+
| Extender enum | `Status.Active` → `Status.Active \| Status.Pending` |
|
|
102
|
+
| Relajar validación | Campo required → opcional |
|
|
103
|
+
|
|
104
|
+
### Cambios breaking (requieren nueva versión)
|
|
105
|
+
|
|
106
|
+
| Cambio | Ejemplo |
|
|
107
|
+
|---|---|
|
|
108
|
+
| Eliminar campo del response | `{ name, email }` → `{ name }` |
|
|
109
|
+
| Renombrar campo | `{ email }` → `{ emailAddress }` |
|
|
110
|
+
| Cambiar tipo de campo | `{ price: string }` → `{ price: number }` |
|
|
111
|
+
| Hacer campo requerido | `{ locale? }` → `{ locale }` |
|
|
112
|
+
| Eliminar endpoint | `DELETE /v1/users` |
|
|
113
|
+
| Cambiar estructura | `{ address: string }` → `{ address: { street, city } }` |
|
|
114
|
+
| Cambiar error codes | `404` → `400` para mismo error |
|
|
115
|
+
|
|
116
|
+
### Compatibilidad en TypeScript
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// shared/contracts/orders/v1/OrderDTO.ts
|
|
120
|
+
export interface OrderV1DTO {
|
|
121
|
+
id: string;
|
|
122
|
+
total: number;
|
|
123
|
+
status: string;
|
|
124
|
+
items: { sku: string; quantity: number }[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// shared/contracts/orders/v2/OrderDTO.ts
|
|
128
|
+
export interface OrderV2DTO extends Omit<OrderV1DTO, "items"> {
|
|
129
|
+
total: number;
|
|
130
|
+
status: string;
|
|
131
|
+
items: { sku: string; quantity: number; name: string; imageUrl: string }[];
|
|
132
|
+
estimatedDelivery: string; // nuevo campo
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Versionado en el Modelo de Forge
|
|
139
|
+
|
|
140
|
+
### Estructura de directorios
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
src/features/orders/
|
|
144
|
+
adapters/
|
|
145
|
+
in/http/
|
|
146
|
+
v1/
|
|
147
|
+
OrderController.ts
|
|
148
|
+
OrderRoutes.ts
|
|
149
|
+
OrderValidator.ts ← validación de input v1
|
|
150
|
+
OrderPresenter.ts ← formateo de output v1
|
|
151
|
+
v2/
|
|
152
|
+
OrderController.ts
|
|
153
|
+
OrderRoutes.ts
|
|
154
|
+
OrderValidator.ts
|
|
155
|
+
OrderPresenter.ts
|
|
156
|
+
application/
|
|
157
|
+
use-cases/ ← los use cases NO se versionan
|
|
158
|
+
GetOrderUseCase.ts ← compartido entre v1 y v2
|
|
159
|
+
PlaceOrderUseCase.ts
|
|
160
|
+
mappers/ ← los mappers pueden tener versiones
|
|
161
|
+
v1/
|
|
162
|
+
OrderMapper.ts ← entidad de dominio → DTO v1
|
|
163
|
+
v2/
|
|
164
|
+
OrderMapper.ts ← entidad de dominio → DTO v2
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Principio: la lógica de negocio no se versiona
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// ✅ Los use cases son compartidos entre versiones
|
|
171
|
+
// v1 y v2 llaman al mismo use case
|
|
172
|
+
// Solo cambia el mapper (cómo se presenta el resultado)
|
|
173
|
+
|
|
174
|
+
// v1 mapper: devuelve el DTO original
|
|
175
|
+
export class OrderV1Mapper {
|
|
176
|
+
toDTO(order: OrderEntity): OrderV1DTO {
|
|
177
|
+
return {
|
|
178
|
+
id: order.id,
|
|
179
|
+
total: order.total,
|
|
180
|
+
status: order.status,
|
|
181
|
+
items: order.items.map((i) => ({ sku: i.sku, quantity: i.quantity })),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// v2 mapper: devuelve más información
|
|
187
|
+
export class OrderV2Mapper {
|
|
188
|
+
toDTO(order: OrderEntity): OrderV2DTO {
|
|
189
|
+
return {
|
|
190
|
+
id: order.id,
|
|
191
|
+
total: order.total,
|
|
192
|
+
status: order.status,
|
|
193
|
+
items: order.items.map((i) => ({
|
|
194
|
+
sku: i.sku,
|
|
195
|
+
quantity: i.quantity,
|
|
196
|
+
name: i.productName,
|
|
197
|
+
imageUrl: i.productImage,
|
|
198
|
+
})),
|
|
199
|
+
estimatedDelivery: this.calculateDelivery(order),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Routing multi-versión
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
// platform/http/Router.ts
|
|
209
|
+
export class ApiRouter {
|
|
210
|
+
constructor() {
|
|
211
|
+
this.registerV1Routes();
|
|
212
|
+
this.registerV2Routes();
|
|
213
|
+
this.registerVersionRedirect();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private registerVersionRedirect(): void {
|
|
217
|
+
// Si el cliente no especifica versión, redirigir a la más reciente estable
|
|
218
|
+
this.router.get("/api/orders", (req, res) => {
|
|
219
|
+
res.redirect("/api/v2/orders");
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Deprecación
|
|
228
|
+
|
|
229
|
+
### Política de deprecación
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
v1: lanzada 2025-01
|
|
233
|
+
v2: lanzada 2026-01 (v1 deprecated)
|
|
234
|
+
v3: lanzada 2027-01 (v1 sunset, v2 deprecated)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Headers de deprecación
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
// Middleware que añade headers de deprecación a versiones antiguas
|
|
241
|
+
export class DeprecationMiddleware {
|
|
242
|
+
private readonly sunsetVersions: Record<string, string> = {
|
|
243
|
+
v1: "2027-01-01", // fecha de retiro
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
handle(req: Request, res: Response, next: NextFunction): void {
|
|
247
|
+
const version = this.extractVersion(req.path);
|
|
248
|
+
const sunsetDate = this.sunsetVersions[version];
|
|
249
|
+
if (sunsetDate) {
|
|
250
|
+
res.setHeader("Sunset", sunsetDate);
|
|
251
|
+
res.setHeader("Deprecation", `true`);
|
|
252
|
+
res.setHeader(
|
|
253
|
+
"Link",
|
|
254
|
+
`</api/v2${req.path.replace(/\/api\/v[0-9]/, "")}>; rel="successor-version"`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
next();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Política de retiro
|
|
263
|
+
|
|
264
|
+
1. **Anunciar**: deprecación con 6 meses de anticipación (header `Deprecation: true` + `Sunset: fecha`)
|
|
265
|
+
2. **Migrar**: soporte simultáneo durante el período de transición
|
|
266
|
+
3. **Monitorear**: tracking de uso de versiones antiguas
|
|
267
|
+
4. **Retirar**: cuando el tráfico de la versión antigua es < 1%, retirar con PR visible
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
# Comando para monitorear uso de versiones
|
|
271
|
+
forge api --usage
|
|
272
|
+
# v1: 12% de requests
|
|
273
|
+
# v2: 88% de requests
|
|
274
|
+
# Sugerencia: v1 está lista para retiro (< 15%)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## Testing Multi-Versión
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
// tests/integration/orders/OrderApi.test.ts
|
|
283
|
+
describe("Orders API", () => {
|
|
284
|
+
const versions = ["v1", "v2"];
|
|
285
|
+
|
|
286
|
+
versions.forEach((version) => {
|
|
287
|
+
describe(`${version} — getOrder`, () => {
|
|
288
|
+
it("returns 200 for existing order", async () => {
|
|
289
|
+
const response = await request(app)
|
|
290
|
+
.get(`/api/${version}/orders/test-id`)
|
|
291
|
+
.expect(200);
|
|
292
|
+
|
|
293
|
+
if (version === "v1") {
|
|
294
|
+
expect(response.body).not.toHaveProperty("estimatedDelivery");
|
|
295
|
+
}
|
|
296
|
+
if (version === "v2") {
|
|
297
|
+
expect(response.body).toHaveProperty("estimatedDelivery");
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("returns same core fields across versions", async () => {
|
|
302
|
+
const [v1Res, v2Res] = await Promise.all([
|
|
303
|
+
request(app).get("/api/v1/orders/test-id"),
|
|
304
|
+
request(app).get("/api/v2/orders/test-id"),
|
|
305
|
+
]);
|
|
306
|
+
|
|
307
|
+
expect(v1Res.body.id).toBe(v2Res.body.id);
|
|
308
|
+
expect(v1Res.body.total).toBe(v2Res.body.total);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Compatibilidad backward en CI
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
# Verificar que v2 no rompe cambios backward
|
|
319
|
+
forge api --check-compatibility
|
|
320
|
+
# Checking orders v1 → v2... ✔ No breaking changes
|
|
321
|
+
# Checking users v1 → v2... ✖ Breaking: 'email' renamed to 'emailAddress'
|
|
322
|
+
# → Crear ADR y plan de deprecación
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## Anti-patrones
|
|
328
|
+
|
|
329
|
+
| Anti-patrón | Problema | Solución |
|
|
330
|
+
|---|---|---|
|
|
331
|
+
| **Versionado global** | Versionar `v1/`, `v2/` para toda la API. Un cambio mínimo en un endpoint fuerza nueva versión de toda la API. | Versionado por recurso/feature. No toda la API tiene que estar en la misma versión. |
|
|
332
|
+
| **Mantener versiones para siempre** | "No podemos romper al cliente X". La deuda de mantener N versiones crece. | Política de retiro explícita (Sunset header + fecha). Máximo 2 versiones activas simultáneas. |
|
|
333
|
+
| **Versionado por "fecha"** | `/api/2025-01/orders`. Sin semántica de estabilidad. | Usar `v1`, `v2` (semver simplificado: major version only). |
|
|
334
|
+
| **Lógica de negocio versionada** | El use case v2 es distinto del v1. La lógica se duplica. | Los use cases son compartidos. Solo versionar la presentación (mappers + controllers). |
|
|
335
|
+
| **Versionado sin deprecación** | Se lanza v2 pero v1 no se depreca oficialmente. Los clientes nunca migran. | Header Deprecation + Sunset. Comunicación proactiva a clientes. |
|
|
336
|
+
| **API versionada pero eventos no** | La API versiona contratos HTTP, pero los eventos internos cambian sin versión. | Versionar schemas de eventos también (ej. `OrderPlaced.v2`). |
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## Conexión con Forge
|
|
341
|
+
|
|
342
|
+
| Comando | Acción |
|
|
343
|
+
|---|---|
|
|
344
|
+
| `forge cast payments` | Crea feature con estructura v1/ en adapters/in/http/ |
|
|
345
|
+
| `forge api --check-compatibility` | Verifica que los cambios no rompen versiones anteriores |
|
|
346
|
+
| `forge api --usage` | Muestra distribución de tráfico entre versiones |
|
|
347
|
+
| `forge reforge` | Migra controllers de v1 a v2, depreca la anterior |
|
|
348
|
+
| `forge inspect` | Reporta versiones sin Deprecation header cuando hay versión superior |
|
|
349
|
+
|
|
350
|
+
## Ver también
|
|
351
|
+
|
|
352
|
+
- `reference/api-design.md` — diseño de APIs REST/GraphQL
|
|
353
|
+
- `reference/adr.md` — ADRs para decisiones de versionado
|
|
354
|
+
- `reference/evolutionary-architecture.md` — evolución guiada de APIs
|