@ronaldjdevfs/forge 1.2.0 → 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 +36 -21
- package/package.json +7 -2
- package/skills/forge/SKILL.md +56 -122
- 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 +86 -0
- package/skills/forge/scripts/forgeSentinel.mjs +184 -0
- package/skills/forge/scripts/forgeSmith-admin.mjs +104 -0
- package/skills/forge/scripts/forgeSmith.mjs +164 -0
- 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/pin.mjs +10 -3
- package/skills/forge/scripts/posttool.mjs +2 -2
- package/skills/forge/scripts/recommendation-engine.mjs +125 -0
- package/skills/forge/scripts/rollback.mjs +5 -3
- package/skills/forge/templates/agents/SKILL.md.template +283 -0
- package/skills/forge/templates/agents/agents/hooks.json +18 -0
- package/skills/forge/templates/agents/claude/CLAUDE.md +17 -16
- package/skills/forge/templates/agents/claude/settings.local.json +18 -0
- package/skills/forge/templates/agents/codex/hooks.json +18 -0
- package/skills/forge/templates/agents/cursor/.cursorrules +22 -5
- package/skills/forge/templates/agents/cursor/hooks.json +11 -0
- package/skills/forge/templates/agents/gemini/SKILL.md +13 -0
- 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 +284 -0
- package/src/agents.mjs +35 -2
- package/src/cli.js +112 -39
- package/src/wizard.mjs +142 -90
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# Idempotency — Operaciones Seguras para Retry
|
|
2
|
+
|
|
3
|
+
En sistemas distribuidos, las operaciones fallan. Los clientes reintentan. Sin idempotencia, un reintento puede crear facturas duplicadas, cargar dos veces la misma tarjeta o enviar un email dos veces. La idempotencia garantiza que N intentos producen el mismo resultado que uno.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Definición
|
|
8
|
+
|
|
9
|
+
Una operación es **idempotente** si ejecutarla una o varias veces produce el mismo efecto secundario.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
GET /orders/123 → idempotente (natural, no cambia estado)
|
|
13
|
+
PUT /orders/123 → idempotente (el mismo body produce el mismo estado final)
|
|
14
|
+
DELETE /orders/123 → idempotente (borrar algo ya borrado no cambia nada)
|
|
15
|
+
POST /orders → NO es idempotente (crea un recurso nuevo cada vez)
|
|
16
|
+
PATCH /orders/123 → NO es idempotente por defecto (depende del delta)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Para hacer POST y PATCH idempotentes se necesita un **idempotency key**.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Idempotency Keys en APIs
|
|
24
|
+
|
|
25
|
+
El cliente envía una clave única en el header `Idempotency-Key`. El servidor:
|
|
26
|
+
1. Si la clave es nueva: ejecuta la operación, guarda la respuesta, la retorna
|
|
27
|
+
2. Si la clave ya existe: retorna la respuesta guardada sin ejecutar la operación
|
|
28
|
+
|
|
29
|
+
### Implementación
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// Platform middleware
|
|
33
|
+
// platform/http/middleware/IdempotencyMiddleware.ts
|
|
34
|
+
export class IdempotencyMiddleware {
|
|
35
|
+
constructor(
|
|
36
|
+
private readonly idempotencyRepo: IIdempotencyRepository,
|
|
37
|
+
private readonly clock: IClock,
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
41
|
+
// Solo para métodos mutantes
|
|
42
|
+
if (!["POST", "PATCH", "PUT"].includes(req.method)) return next();
|
|
43
|
+
|
|
44
|
+
const key = req.headers["idempotency-key"] as string;
|
|
45
|
+
if (!key) {
|
|
46
|
+
// POST sin key es válido pero no tiene protección de idempotencia
|
|
47
|
+
// Para operaciones críticas (pagos), debería ser obligatorio
|
|
48
|
+
return next();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Validar formato UUID
|
|
52
|
+
if (!this.isValidUUID(key)) {
|
|
53
|
+
return res.status(400).json({
|
|
54
|
+
error: "Invalid idempotency key format. Must be a UUID v4.",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const existing = await this.idempotencyRepo.find(key);
|
|
59
|
+
if (existing) {
|
|
60
|
+
// Clave ya usada → devolver respuesta en caché
|
|
61
|
+
// Si la clave expiró, rechazar con 422
|
|
62
|
+
if (existing.isExpired(this.clock)) {
|
|
63
|
+
return res.status(422).json({
|
|
64
|
+
error: "Idempotency key expired. Use a new key.",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return res.status(existing.statusCode).json(existing.responseBody);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Registrar clave antes de ejecutar (protege contra duplicados simultáneos)
|
|
71
|
+
await this.idempotencyRepo.save(IdempotencyEntry.pending(key, req));
|
|
72
|
+
|
|
73
|
+
// Ejecutar y guardar respuesta
|
|
74
|
+
res.on("finish", async () => {
|
|
75
|
+
await this.idempotencyRepo.save(
|
|
76
|
+
IdempotencyEntry.completed(key, res.statusCode, res.body),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
next();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Repositorio de idempotencia
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
// infra/redis/IdempotencyRepository.ts
|
|
89
|
+
export class RedisIdempotencyRepository implements IIdempotencyRepository {
|
|
90
|
+
private readonly TTL_SECONDS = 60 * 60 * 24; // 24 horas
|
|
91
|
+
|
|
92
|
+
constructor(private readonly redis: Redis) {}
|
|
93
|
+
|
|
94
|
+
async find(key: string): Promise<IdempotencyEntry | null> {
|
|
95
|
+
const data = await this.redis.get(`idempotency:${key}`);
|
|
96
|
+
if (!data) return null;
|
|
97
|
+
return IdempotencyEntry.fromJSON(data);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async save(entry: IdempotencyEntry): Promise<void> {
|
|
101
|
+
await this.redis.set(
|
|
102
|
+
`idempotency:${entry.key}`,
|
|
103
|
+
entry.toJSON(),
|
|
104
|
+
"EX",
|
|
105
|
+
this.TTL_SECONDS,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Contract en shared/
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
// shared/contracts/http/IIdempotencyRepository.ts
|
|
115
|
+
export interface IIdempotencyRepository {
|
|
116
|
+
find(key: string): Promise<IdempotencyEntry | null>;
|
|
117
|
+
save(entry: IdempotencyEntry): Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Reglas del Idempotency Key
|
|
122
|
+
|
|
123
|
+
1. **Generado por el cliente** (nunca por el servidor): el cliente necesita poder reintentar sin haber recibido respuesta
|
|
124
|
+
2. **Formato UUID v4**: único, no secuencial, sin colisiones
|
|
125
|
+
3. **TTL**: 24 horas típicamente. Si el cliente reintenta después del TTL, la clave expiró y debe generar una nueva
|
|
126
|
+
4. **Único por recurso/operación**: un mismo key no debe reusarse para operaciones distintas
|
|
127
|
+
5. **Protección de concurrencia**: dos requests simultáneos con el mismo key deben producir exactamente una ejecución (lock optimista en Redis)
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Idempotencia en Eventos
|
|
132
|
+
|
|
133
|
+
Los consumidores de eventos deben deduplicar por event ID para garantizar exactly-once processing.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
// base consumer pattern
|
|
137
|
+
export abstract class IdempotentEventConsumer<T extends DomainEvent> {
|
|
138
|
+
constructor(
|
|
139
|
+
protected readonly processedEvents: IProcessedEventRepository,
|
|
140
|
+
) {}
|
|
141
|
+
|
|
142
|
+
async handle(event: T): Promise<void> {
|
|
143
|
+
// 1. Deduplicación
|
|
144
|
+
const alreadyProcessed = await this.processedEvents.exists(event.eventId);
|
|
145
|
+
if (alreadyProcessed) return;
|
|
146
|
+
|
|
147
|
+
// 2. Ejecutar lógica de negocio
|
|
148
|
+
await this.processEvent(event);
|
|
149
|
+
|
|
150
|
+
// 3. Marcar como procesado (idealmente en la misma transacción)
|
|
151
|
+
await this.processedEvents.markProcessed(event.eventId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
protected abstract processEvent(event: T): Promise<void>;
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// Implementación concreta
|
|
160
|
+
export class OrderPlacedHandler extends IdempotentEventConsumer<OrderPlacedEvent> {
|
|
161
|
+
constructor(
|
|
162
|
+
processedEvents: IProcessedEventRepository,
|
|
163
|
+
private readonly inventoryService: IInventoryService,
|
|
164
|
+
) {
|
|
165
|
+
super(processedEvents);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
protected async processEvent(event: OrderPlacedEvent): Promise<void> {
|
|
169
|
+
await this.inventoryService.reserveStock(event.orderId, event.items);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Tabla de eventos procesados
|
|
175
|
+
|
|
176
|
+
```sql
|
|
177
|
+
CREATE TABLE IF NOT EXISTS public.processed_events (
|
|
178
|
+
event_id VARCHAR(255) PRIMARY KEY,
|
|
179
|
+
consumer_name VARCHAR(255) NOT NULL,
|
|
180
|
+
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
181
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
-- Cleanup periódico: eventos procesados > 7 días
|
|
185
|
+
CREATE INDEX idx_processed_events_cleanup ON public.processed_events (processed_at);
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Idempotencia Natural por Operación
|
|
191
|
+
|
|
192
|
+
| Operación HTTP | Idempotencia natural | Cómo hacerla idempotente |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| **GET** | ✅ Sí | Nada. Es idempotente por definición. |
|
|
195
|
+
| **PUT** | ✅ Sí | Mismo body → mismo estado. |
|
|
196
|
+
| **DELETE** | ✅ Sí | Borrar algo ya borrado no cambia estado. |
|
|
197
|
+
| **POST** | ❌ No | Idempotency-Key header. |
|
|
198
|
+
| **PATCH** | ❌ No | Idempotency-Key header + operaciones basadas en estado (ej. "set status = X" es idempotente, "increment count" no lo es). |
|
|
199
|
+
|
|
200
|
+
### Operaciones condicionalmente idempotentes
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
// ❌ NO idempotente: el resultado cambia cada vez
|
|
204
|
+
PATCH /orders/123 { "action": "addItem", "productId": "abc" }
|
|
205
|
+
|
|
206
|
+
// ✅ Idempotente: el resultado es el mismo estado final
|
|
207
|
+
PUT /orders/123 { "items": ["abc", "def"] }
|
|
208
|
+
|
|
209
|
+
// ✅ Condicionalmente idempotente con idempotency key
|
|
210
|
+
POST /orders/123/items
|
|
211
|
+
Idempotency-Key: uuid-abc-123
|
|
212
|
+
Body: { "productId": "abc", "quantity": 1 }
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Implementación en el Modelo de Forge
|
|
218
|
+
|
|
219
|
+
### Estructura
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
src/platform/
|
|
223
|
+
http/
|
|
224
|
+
middleware/
|
|
225
|
+
IdempotencyMiddleware.ts ← middleware que intercepta requests
|
|
226
|
+
contracts/
|
|
227
|
+
IIdempotencyRepository.ts ← interfaz en shared/
|
|
228
|
+
|
|
229
|
+
src/infra/
|
|
230
|
+
redis/
|
|
231
|
+
IdempotencyRepository.ts ← implementación con Redis
|
|
232
|
+
|
|
233
|
+
src/features/<name>/
|
|
234
|
+
adapters/
|
|
235
|
+
in/
|
|
236
|
+
events/
|
|
237
|
+
<Name>Handler.ts ← consumer con deduplicación
|
|
238
|
+
out/
|
|
239
|
+
persistence/
|
|
240
|
+
ProcessedEventRepository.ts ← tabla processed_events
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Reglas de ubicación
|
|
244
|
+
|
|
245
|
+
| Componente | Capa | Razón |
|
|
246
|
+
|---|---|---|
|
|
247
|
+
| Middleware HTTP | platform/ | Es infraestructura transversal |
|
|
248
|
+
| Interfaz IIdempotencyRepository | shared/contracts/ | Contrato puro sin implementación |
|
|
249
|
+
| Implementación Redis | infra/ | Implementación concreta |
|
|
250
|
+
| IdempotencyKey generación | Cliente (frontend/CLI) | El cliente necesita la clave antes del request |
|
|
251
|
+
| Deduplicación en consumidores | feature/adapters/in/events/ | Específico del feature |
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Anti-patrones
|
|
256
|
+
|
|
257
|
+
| Anti-patrón | Problema | Solución |
|
|
258
|
+
|---|---|---|
|
|
259
|
+
| **Idempotency sin expiración** | Las claves se acumulan infinitamente. | TTL obligatorio (24h típico). Configurable por operación. |
|
|
260
|
+
| **Claves generadas por el servidor** | El servidor genera la key y la devuelve. Si la respuesta se pierde, el cliente no puede reintentar. | El cliente genera la key (UUID v4). El servidor solo la valida. |
|
|
261
|
+
| **Idempotencia en GET** | GET debe ser idempotente por definición. Si no lo es, el problema es otro (efectos secundarios en GET). | No añadir idempotency keys a GET. Mover efectos secundarios a POST/PUT. |
|
|
262
|
+
| **Mutex como idempotencia** | Bloquear el recurso en vez de deduplicar la operación. | Idempotencia no es locking. La clave permite deduplicar sin bloquear. |
|
|
263
|
+
| **Idempotency key corta** | Claves predecibles (incrementales, timestamp). Un cliente puede adivinar claves de otros. | UUID v4 obligatorio. No secuencial. No predecible. |
|
|
264
|
+
| **No verificar método** | El middleware aplica a GET también, añadiendo latencia innecesaria. | Solo POST, PATCH, PUT. GET pasa sin verificar. |
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Conexión con Forge
|
|
269
|
+
|
|
270
|
+
| Comando | Acción |
|
|
271
|
+
|---|---|
|
|
272
|
+
| `forge cast` | Incluir idempotency middleware + repo Redis en features críticas |
|
|
273
|
+
| `forge quench` | Regla detect: feature con POST sin idempotency key |
|
|
274
|
+
| `forge inspect` | Reporta endpoints sin protección de idempotencia |
|
|
275
|
+
| `forge api` | Verifica que los contratos OpenAPI incluyen idempotency-key header |
|
|
276
|
+
| `forge temper` | Endurece DI del middleware de idempotencia |
|
|
277
|
+
|
|
278
|
+
## Ver también
|
|
279
|
+
|
|
280
|
+
- `reference/transactional-outbox.md` — deduplicación en relayer
|
|
281
|
+
- `reference/api-design.md` — idempotency-key header en APIs
|
|
282
|
+
- `reference/events.md` — handlers idempotentes
|
|
283
|
+
- `reference/sagas.md` — retry seguro en sagas
|
|
@@ -127,3 +127,8 @@ Genera y mantiene el archivo `ARCHITECTURE.md` del proyecto.
|
|
|
127
127
|
- Se actualiza automáticamente después de cada comando
|
|
128
128
|
- No editar manualmente los campos auto-detectados
|
|
129
129
|
- Forge preserva cualquier sección adicional que el usuario agregue
|
|
130
|
+
|
|
131
|
+
## Ver también
|
|
132
|
+
|
|
133
|
+
- `reference/adr.md` — Architecture Decision Records como insumo para ARCHITECTURE.md
|
|
134
|
+
- `reference/principles.md` — principios arquitectónicos documentados en ARCHITECTURE.md
|
|
@@ -55,4 +55,10 @@ Inspecciona la conformidad arquitectónica del proyecto.
|
|
|
55
55
|
node .opencode/skills/forge/scripts/inspect.mjs
|
|
56
56
|
node .opencode/skills/forge/scripts/inspect.mjs --json
|
|
57
57
|
node .opencode/skills/forge/scripts/detect.mjs --severity ERROR
|
|
58
|
+
|
|
59
|
+
## Ver también
|
|
60
|
+
|
|
61
|
+
- `reference/evolutionary-architecture.md` — fitness functions como complemento al inspect
|
|
62
|
+
- `reference/assay.md` — interpretación cualitativa multi-persona del reporte de inspect
|
|
63
|
+
- `reference/principles.md` — principios contra los que inspect evalúa el proyecto
|
|
58
64
|
```
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# Modular Monolith
|
|
2
|
+
|
|
3
|
+
No todo proyecto necesita microservicios. El modular monolith es el punto óptimo para la mayoría de los equipos: la modularidad de features de Forge con la simplicidad operativa de un solo deploy.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Espectro Monolith → Modular → Microservices
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Monolith clásico Modular Monolith Microservicios
|
|
11
|
+
┌──────────────────┐ ┌──────────────────┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐
|
|
12
|
+
│ Todo en una capa │ │ ┌─┐ ┌─┐ ┌─┐ ┌─┐│ │F1│ │F2│ │F3│ │F4│
|
|
13
|
+
│ Sin boundaries │ │ │F1│ │F2│ │F3│ │F4││ └──┘ └──┘ └──┘ └──┘
|
|
14
|
+
│ BD compartida │ │ └─┘ └─┘ └─┘ └─┘│ BD separadas
|
|
15
|
+
│ Sin features │ │ Platform/Shared/Infra │ APIs síncronas
|
|
16
|
+
└──────────────────┘ └──────────────────┘ │ Eventos asíncronos
|
|
17
|
+
│ Deploy independiente
|
|
18
|
+
└───────────────────────────
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Aspecto | Monolith clásico | Modular Monolith | Microservicios |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| **Boundaries** | No existen | Features con R8-R9 | Servicios independientes |
|
|
24
|
+
| **Comunicación** | Llamadas directas | Inyección de interfaces + eventos | HTTP/gRPC + eventos |
|
|
25
|
+
| **Deploy** | Todo junto | Todo junto | Independiente |
|
|
26
|
+
| **BD** | Compartida | Schemas separados por feature | BD independiente |
|
|
27
|
+
| **Equipo** | 1 equipo | 1-2 equipos | 3+ equipos |
|
|
28
|
+
| **Forge** | No aplica | Modelo nativo | Modelo nativo + split |
|
|
29
|
+
| **Escala** | < 20 features | < 40 features | Sin límite |
|
|
30
|
+
|
|
31
|
+
El modular monolith NO es un monolith clásico. La diferencia son los **boundaries explícitos**: cada feature respeta R8 (no imports directos) y R9 (no ciclos), igual que en microservicios, pero todo corre en el mismo proceso.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Marco de Decisión
|
|
36
|
+
|
|
37
|
+
### Cuándo mantener Modular Monolith
|
|
38
|
+
|
|
39
|
+
| Condición | Señal |
|
|
40
|
+
|---|---|
|
|
41
|
+
| Equipo pequeño (< 8 personas) | Un solo equipo puede mantener todas las features |
|
|
42
|
+
| Dominio cohesionado | Los bounded contexts están estrechamente relacionados |
|
|
43
|
+
| Latencia crítica | El overhead de red de microservicios es inaceptable |
|
|
44
|
+
| Fase inicial | El dominio no está lo suficientemente entendido para partir |
|
|
45
|
+
| Baja carga operativa | Sin equipo de infraestructura dedicado |
|
|
46
|
+
| Deploy semanal/mensual | La velocidad de deploy no es el cuello de botella |
|
|
47
|
+
|
|
48
|
+
### Cuándo considerar partir a microservicios
|
|
49
|
+
|
|
50
|
+
| Señal | Síntoma |
|
|
51
|
+
|---|---|
|
|
52
|
+
| **Team scaling** | Dos equipos distintos modifican la misma feature frecuentemente |
|
|
53
|
+
| **Deployment bottleneck** | Un cambio en una feature requiere validar todo el monolith |
|
|
54
|
+
| **Boundaries maduros** | Los bounded contexts están estables y bien definidos |
|
|
55
|
+
| **Failure isolation** | Una feature con alta criticidad (ej. pagos) arrastra a las demás en fallos |
|
|
56
|
+
| **Diferentes características** | Una feature necesita escalar distinto (ej. Search vs Catalog) |
|
|
57
|
+
| **Stack divergence** | Una feature se beneficiaría de un stack tecnológico distinto |
|
|
58
|
+
|
|
59
|
+
### Contra-señales (no partir)
|
|
60
|
+
|
|
61
|
+
| Falsa señal | Realidad |
|
|
62
|
+
|---|---|
|
|
63
|
+
| "Es lo moderno" | Microservicios son una decisión de negocio, no técnica |
|
|
64
|
+
| "Escalabilidad" | El cuello de botella suele ser BD, no el monolith |
|
|
65
|
+
| "Equipos autónomos" | Sin boundaries bien definidos, los microservicios serán un distributed monolith |
|
|
66
|
+
| "Rendimiento" | El overhead de red puede empeorar la latencia |
|
|
67
|
+
| "Está de moda" | Moda tecnológica no justifica la complejidad operativa |
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Arquitectura Interna del Modular Monolith
|
|
72
|
+
|
|
73
|
+
### Comunicación entre features
|
|
74
|
+
|
|
75
|
+
En el modular monolith, la comunicación entre features sigue exactamente las mismas reglas que en microservicios:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
// ✅ Permitido: inyección de interfaz desde shared
|
|
79
|
+
// src/features/orders/application/use-cases/PlaceOrderUseCase.ts
|
|
80
|
+
import { IInventoryService } from "src/shared/contracts/catalog";
|
|
81
|
+
import { IUnitOfWork } from "src/shared/contracts/data";
|
|
82
|
+
|
|
83
|
+
// ❌ Prohibido: import directo a otra feature (R8)
|
|
84
|
+
import { StockEntity } from "src/features/inventory/domain/StockEntity";
|
|
85
|
+
|
|
86
|
+
// ❌ Prohibido: import directo a infra de otra feature (R1 + R8)
|
|
87
|
+
import { prisma } from "src/features/inventory/adapters/out/persistence/prisma";
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Shared Kernel
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
// src/shared/contracts/catalog/IInventoryService.ts
|
|
94
|
+
export interface IInventoryService {
|
|
95
|
+
reserveStock(orderId: string, items: LineItem[]): Promise<ReservationResult>;
|
|
96
|
+
releaseStock(reservationId: string): Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Los DTOs de los contratos viven junto a la interfaz
|
|
100
|
+
export type ReservationResult = {
|
|
101
|
+
success: boolean;
|
|
102
|
+
reservationId?: string;
|
|
103
|
+
insufficientItems: { sku: string; available: number }[];
|
|
104
|
+
};
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
La implementación se inyecta en tiempo de construcción:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
// src/features/inventory/adapters/out/shared/InventoryService.ts
|
|
111
|
+
export class InventoryServiceImpl implements IInventoryService {
|
|
112
|
+
constructor(private readonly stockRepo: IStockRepository) {}
|
|
113
|
+
// implementación real que la feature Inventory expone al Shared Kernel
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Base de datos
|
|
118
|
+
|
|
119
|
+
En el modular monolith, cada feature tiene su propio schema dentro de la misma BD:
|
|
120
|
+
|
|
121
|
+
```sql
|
|
122
|
+
-- Schema por feature, misma base de datos
|
|
123
|
+
CREATE SCHEMA IF NOT EXISTS orders;
|
|
124
|
+
CREATE SCHEMA IF NOT EXISTS catalog;
|
|
125
|
+
CREATE SCHEMA IF NOT EXISTS inventory;
|
|
126
|
+
|
|
127
|
+
CREATE TABLE orders.orders ( … );
|
|
128
|
+
CREATE TABLE catalog.products ( … );
|
|
129
|
+
CREATE TABLE inventory.stock ( … );
|
|
130
|
+
|
|
131
|
+
-- No existen foreign keys entre schemas de distintas features
|
|
132
|
+
-- La integridad referencial se maneja en la aplicación, no en la BD
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
En Prisma esto se configura con `schema` por modelo:
|
|
136
|
+
|
|
137
|
+
```prisma
|
|
138
|
+
generator client {
|
|
139
|
+
provider = "prisma-client-js"
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
datasource db {
|
|
143
|
+
provider = "postgresql"
|
|
144
|
+
url = env("DATABASE_URL")
|
|
145
|
+
schemas = ["orders", "catalog", "inventory"]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
model Order {
|
|
149
|
+
id String @id @default(uuid())
|
|
150
|
+
// ...
|
|
151
|
+
@@schema("orders")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
model Product {
|
|
155
|
+
id String @id @default(uuid())
|
|
156
|
+
// ...
|
|
157
|
+
@@schema("catalog")
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Eventos dentro del monolith
|
|
162
|
+
|
|
163
|
+
Dentro del modular monolith, los eventos pueden ser sincrónicos (in-process event bus) o asíncronos (message broker):
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
// Sincrónico: EventBus in-process, sin serialización
|
|
167
|
+
// platform/events/EventBus.ts
|
|
168
|
+
export interface IEventBus {
|
|
169
|
+
publish(event: DomainEvent): Promise<void>;
|
|
170
|
+
subscribe<T extends DomainEvent>(eventType: string, handler: (event: T) => Promise<void>): void;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Uso en feature
|
|
174
|
+
// orders/application/use-cases/PlaceOrderUseCase.ts
|
|
175
|
+
await this.eventBus.publish(new OrderPlacedEvent(order));
|
|
176
|
+
// inventory/adapters/in/events/OrderPlacedHandler.ts
|
|
177
|
+
eventBus.subscribe("OrderPlaced", async (event) => {
|
|
178
|
+
await this.stockService.reserveStock(event.orderId, event.items);
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**Regla: dentro del monolith, puedes elegir síncrono o asíncrono.** Pero si planeas partir a microservicios en el futuro, usa asíncrono desde el día 1 (message broker). La migración será solo cambiar la URL del broker.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Integración con Forge
|
|
187
|
+
|
|
188
|
+
| Elemento | Modular Monolith | Microservicios |
|
|
189
|
+
|---|---|---|
|
|
190
|
+
| **Reglas R1-R9** | Se aplican igual | Se aplican igual |
|
|
191
|
+
| **Contratos en shared/** | Interfaces + DTOs | Mismos contratos (packages publicados) |
|
|
192
|
+
| **Eventos** | In-process o broker | Broker siempre |
|
|
193
|
+
| **Platform** | Monolítica, compartida | Por servicio o shared library |
|
|
194
|
+
| **Infra** | Schemas separados en misma BD | BD independientes |
|
|
195
|
+
| **Deploy** | Un solo artefacto | N artefactos |
|
|
196
|
+
| **forge cast** | Crea feature en el monolith | Crea feature + esqueleto de servicio |
|
|
197
|
+
| **forge inspect** | Auditoría normal | Auditoría + health check distribuido |
|
|
198
|
+
| **forge relocate** | Extraer feature del monolith | Dividir servicio o fusionar |
|
|
199
|
+
|
|
200
|
+
### Transición guiada: Modular Monolith → Microservicios
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
Fase 1: Asegurar boundaries
|
|
204
|
+
- Verificar que R8 y R9 se cumplen
|
|
205
|
+
- shared/contracts/ debe contener todas las interfaces entre features
|
|
206
|
+
- Schemas separados por feature
|
|
207
|
+
|
|
208
|
+
Fase 2: Extraer eventos
|
|
209
|
+
- Migrar de event bus in-process a broker (RabbitMQ, Kafka)
|
|
210
|
+
- Verificar que los handlers de eventos son idempotentes
|
|
211
|
+
|
|
212
|
+
Fase 3: Extraer feature como servicio
|
|
213
|
+
1. forge relocate extrae la feature a un nuevo repo
|
|
214
|
+
2. La feature origen ahora inyecta un cliente HTTP/gRPC en vez de la impl directa
|
|
215
|
+
3. El contrato en shared/ se convierte en API contract
|
|
216
|
+
4. Los eventos viajan por el broker existente
|
|
217
|
+
|
|
218
|
+
Fase 4: Iterar
|
|
219
|
+
- Repetir Fase 3 para cada feature que se beneficie de partir
|
|
220
|
+
- Dejar las demás en el monolith
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Anti-patrones
|
|
226
|
+
|
|
227
|
+
| Anti-patrón | Problema | Solución |
|
|
228
|
+
|---|---|---|
|
|
229
|
+
| **Distributed Monolith** | Microservicios que se llaman sincrónicamente en cadena. Si un servicio falla, todos fallan. | Usar eventos asíncronos entre servicios. Si la latencia lo exige sincrónico, reconsiderar si deberían ser un solo servicio. |
|
|
230
|
+
| **Shared Database (microservices)** | Microservicios que comparten BD. Los boundaries son ficticios. | Migrar a schemas separados o BD independientes. Empezar con schemas si el split es futuro. |
|
|
231
|
+
| **Nano-services** | Microservicio por entidad. Cada servicio es un CRUD sin lógica. | Fusionar en features por dominio de negocio. El overhead de operar N servicios pequeños es mayor que el beneficio. |
|
|
232
|
+
| **Premature splitting** | Partir antes de entender los bounded contexts. | Esperar a que los contexts estén estables. El monolith modular permite partir sin reescribir. |
|
|
233
|
+
| **Monolith con carpetas** | Solo hay separación por carpetas, no por boundaries. Cualquier código importa cualquier otro. | Implementar R8 y R9. Si duele, significa que los boundaries están mal diseñados o no existen. |
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Conexión con Forge
|
|
238
|
+
|
|
239
|
+
| Comando | Acción |
|
|
240
|
+
|---|---|
|
|
241
|
+
| `forge cast` | Crea feature en el monolith modular con boundaries correctos |
|
|
242
|
+
| `forge inspect` | Verifica que los boundaries entre features son saludables |
|
|
243
|
+
| `forge relocate` | Extrae una feature del monolith a un servicio independiente |
|
|
244
|
+
| `forge reforge` | Rediseña boundaries mal definidos dentro del monolith |
|
|
245
|
+
| `forge graph` | Visualiza dependencias entre features; identifica candidatos a partir |
|
|
246
|
+
|
|
247
|
+
## Ver también
|
|
248
|
+
|
|
249
|
+
- `reference/bounded-contexts.md` — contexts como módulos del monolith
|
|
250
|
+
- `reference/anti-corruption-layer.md` — ACL entre módulos
|
|
251
|
+
- `reference/sagas.md` — coordinación entre módulos
|
|
252
|
+
- `reference/events.md` — comunicación asíncrona entre módulos
|
|
@@ -64,3 +64,8 @@ export interface IHealthCheck {
|
|
|
64
64
|
- Tracing distribuido con baggage contextual
|
|
65
65
|
- Health checks sin autenticación (solo para orquestadores)
|
|
66
66
|
- Alertas basadas en métricas, no en logs
|
|
67
|
+
|
|
68
|
+
## Ver también
|
|
69
|
+
|
|
70
|
+
- `reference/security-patterns.md` — audit logging como práctica de seguridad
|
|
71
|
+
- `reference/testing-patterns.md` — tests de instrumentación y health checks
|
|
@@ -71,4 +71,9 @@ node .opencode/skills/forge/scripts/detect.mjs --severity ERROR
|
|
|
71
71
|
|
|
72
72
|
# Solo un tipo específico
|
|
73
73
|
node .opencode/skills/forge/scripts/detect.mjs --type layers
|
|
74
|
+
|
|
75
|
+
## Ver también
|
|
76
|
+
|
|
77
|
+
- `reference/evolutionary-architecture.md` — fitness functions como validación continua
|
|
78
|
+
- `reference/hooks.md` — integración de quench en pre-commit hook
|
|
74
79
|
```
|
|
@@ -49,3 +49,9 @@ El backup se almacena en `.forge/backups/<target>--<timestamp>/` y preserva la e
|
|
|
49
49
|
| Feature | `src/application/use-cases/<name>/` | `src/features/<name>/` |
|
|
50
50
|
| Shared | `src/utils/`, `src/helpers/`, `src/lib/` | `src/shared/<name>/` |
|
|
51
51
|
| Infra | `src/database/`, `src/providers/` | `src/infra/<name>/` |
|
|
52
|
+
|
|
53
|
+
## Ver también
|
|
54
|
+
|
|
55
|
+
- `reference/anti-corruption-layer.md` — aislamiento de legacy durante migración
|
|
56
|
+
- `reference/modular-monolith.md` — decisión de estructura al migrar features
|
|
57
|
+
- `reference/reforge.md` — refactor post-migración
|