| name | ddd-tactical-design |
| description | Ejecuta el Paso 2 del framework de diseño de sistemas con DDD: Diseño Táctico de un Bounded Context. Dado un BC ya definido en el Paso 1 (arch/system/system.yaml), genera los seis artefactos canónicos del BC: {bc-name}.yaml, {bc-name}-spec.md, {bc-name}-flows.md, diagrams/, {bc-name}-open-api.yaml y {bc-name}-async-api.yaml en el directorio arch/{bc-name}/. Usar SIEMPRE que el usuario quiera diseñar, detallar o expandir un bounded context específico. Aplica cuando diga frases como "diseña el BC de...", "construye el bounded context de...", "vamos con el paso 2 de...", "detalla el dominio de...", "crea los artefactos de...", "modela el BC de...", o cuando mencione el nombre de un BC existente en el sistema y pida avanzar con su diseño táctico. NO diseñar ningún BC que no esté definido en arch/system/system.yaml — validar siempre antes de comenzar.
|
DDD Paso 2 — Diseño Táctico de un Bounded Context
Este skill produce el diseño táctico completo de un Bounded Context. Al finalizar,
existen seis artefactos en arch/{bc-name}/:
arch/{bc-name}/
├── {bc-name}.yaml ← anatomía del dominio (fuente de verdad táctica)
├── {bc-name}-spec.md ← casos de uso detallados
├── {bc-name}-flows.md ← flujos de validación Given/When/Then
├── {bc-name}-open-api.yaml ← contratos REST (OpenAPI 3.1.0)
├── {bc-name}-internal-api.yaml ← contratos REST internos BC-a-BC (condicional — ver §3.6)
├── {bc-name}-async-api.yaml ← contratos de eventos (AsyncAPI 2.6.0)
└── diagrams/
├── {bc-name}-diagram.mmd ← casos de uso (flowchart) — SIEMPRE
├── {bc-name}-diagram-domain-model.mmd ← modelo de dominio (classDiagram) — SIEMPRE
├── {bc-name}-diagram-{entity}-states.mmd ← 1 por enum con transitions (ej: category-states)
├── {bc-name}-diagram-{op-kebab}-seq.mmd ← 1 por operación outbound (ej: product-activated-seq)
└── {bc-name}-diagram-{readmodel-kebab}-sync-seq.mmd ← 1 por agregado readModel (condicional)
REGLA ABSOLUTA: Validación Previa al Diseño
NUNCA comenzar el diseño táctico sin validar primero.
Antes de cualquier acción de diseño:
- Leer
arch/system/system.yaml
- Verificar que el BC solicitado existe en
boundedContexts[].name
- Si NO existe → detener y comunicar al usuario con el listado de BCs disponibles
- Si existe → extraer del
system.yaml:
purpose del BC
aggregates con sus root y entities
- Todas las
integrations donde este BC aparece como from o to
- Los
externalSystems referenciados
- Las decisiones de
infrastructure
- Si existe
sagas[]: los pasos donde step.bc coincide con este BC — identificar triggeredBy, onSuccess, onFailure y compensation por cada paso
También leer arch/system/system-spec.md para obtener el lenguaje ubícuo, las
responsabilidades y no-responsabilidades ya definidas en el Paso 1.
Tu Rol Durante Esta Sesión
Asumes dos voces expertas simultáneas durante todo el proceso de diseño.
Voz 1: Experto de Negocio Especializado en el BC
Conoces el BC desde adentro. Para catalog piensas como un product manager de
e-commerce. Para payments piensas como un especialista en medios de pago. Para
dispatch piensas como un jefe de flota logística.
- Nombras entidades y Value Objects con el lenguaje que usaría el negocio
- Identificas los casos de uso que realmente ocurren en la operación del día a día
- Detectas flujos de excepción que el negocio vive cotidianamente
- Cuestionas si las reglas de negocio capturadas reflejan la operación real
- Sabes qué datos son críticos para el negocio y cuáles son plomería
Voz 2: Ingeniero Senior de Diseño de Sistemas
Conoces los principios de diseño táctico DDD y sus trade-offs.
- Decides qué es Agregado vs Entidad vs Value Object con criterio de invariantes
- Detectas cuando una propiedad debería ser un VO (valor con semántica) vs tipo primitivo
- Aplicas los tipos canónicos correctos (ver referencias)
- Diseñas los contratos de API REST con criterio de API design (recursos, verbos, codes)
- Diseñas los contratos de eventos con criterio de schema evolution y consumidores
Cuando las dos voces produzcan tensión, explicítalo al usuario como parte
del análisis y pregunta antes de asumir.
Fase 1: Análisis de Contexto
1.1 Leer los artefactos del Paso 1
Ejecutar en paralelo:
- Leer
arch/system/system.yaml — completo
- Leer
arch/system/system-spec.md — sección del BC objetivo
Extraer y tener presente durante todo el diseño:
- Agregados y entidades ya identificados (son el punto de partida, no la lista final)
- Integraciones sincrónicas y asíncronas donde participa este BC
- Sistemas externos con los que se integra
- Lenguaje ubícuo ya definido
1.2 Verificar si el BC ya tiene diseño parcial
Si existe arch/{bc-name}/ con archivos:
- Leer lo que existe
- Preguntar al usuario si continúa, reemplaza o refina
1.3 Capacidades soportadas por el generador (LEER ANTES DE DISEÑAR)
El generador soporta un vocabulario extendido para cada sección del BC.
No usar capacidades fuera de este inventario — el generador rechazará el YAML.
Aggregates
concurrencyControl: optimistic (único valor admitido).
- Propiedades:
- Flags:
readOnly, hidden, internal, unique, indexed, defaultValue, source: authContext.
validations[] declarativas — vocabulario soportado por el generador (whitelist exacta): notEmpty, minLength, pattern, min, max, positive, positiveOrZero, negative, negativeOrZero, future, futureOrPresent, past, pastOrPresent, minSize, maxSize. maxLength no se declara explícitamente: ya está implícito en String(n). Para semanticas ya cubiertas por tipos canónicos (email, url) usar los tipos Email, Url (validan en su constructor) en vez de claves email/url. Ver references/validation.md para la referencia completa.
- Entidades hijas:
relationship: composition|aggregation + cardinality: oneToOne|oneToMany. manyToMany NO soportado. IDs solo Uuid.
softDelete: true (en agregado o entidad). Inyecta deletedAt. Resolución vía softDelete qualifier (countNonDeletedBy*).
- Auto equals/hashCode/toString por id (no declarar manualmente).
domainRules[].type whitelist:
uniqueness — errorCode requerido. field (camelCase, referencia a una propiedad del agregado): opcional pero muy recomendado — sin field el generador emite un TODO enriquecido en el handler (no aborta el build). Con field: genera guardia proactiva en handler (findBy{Campo} pre-check). Con field + constraintName (snake_case): además genera mapeo reactivo de DataIntegrityViolationException. constraintName requiere field — el build falla si constraintName está sin field. ⚠️ fields[] (plural) no existe — solo field (singular) está en la whitelist.
statePrecondition — siempre con errorCode. Genera TODO enriquecido en el handler; la condición concreta la implementa Fase 3.
terminalState — errorCode traduce al error de transición inválida. No declarar transiciones FROM el estado terminal en el enum.
sideEffect — sin errorCode (el generador no emite error visible al cliente). El generador no produce código ejecutable — es anotación de diseño pura para Fase 3.
deleteGuard — requiere targetAggregate (PascalCase) + targetRepositoryMethod (camelCase). Ambos deben declararse juntos o ninguno — el build falla si solo uno está presente. Sin ellos: TODO enriquecido.
crossAggregateConstraint — requiere targetAggregate + field + expectedStatus. Los tres deben declararse juntos — el build falla si alguno de los tres está sin los otros dos.
domainMethods[] — métodos de comportamiento invocables por command UCs:
name (camelCase) — referenciado exactamente por useCases[].method.
signature (string DSL, formato preferido): "methodName(param1: Type, param2?): ReturnType". Parámetros sin :Type → el generador resuelve el tipo buscando una propiedad con el mismo nombre en el agregado. Parámetros con ? → opcionales.
description (texto libre) → genera Javadoc en el método Java. Añadir siempre.
returns — obligatorio void o el nombre del agregado. Para el método create: DEBE ser el nombre del agregado — el build falla con error S23 si es void o distinto.
emits — string (evento único) o lista YAML de strings; cada entrada debe existir en domainEvents.published[].
- Formato alternativo:
params: [{name, type}] (válido pero menos explícito que signature). Solo usar si el contexto hace a signature difícil de leer.
method: delete — método reservado de repositorio: un command UC puede declarar method: delete sin que exista un domainMethod con ese nombre en el agregado. El generador lo mapea directamente a repository.deleteById(id). Usar exclusivamente para eliminaciones físicas. Para eliminar con lógica de negocio (guardia, validación de estado, emisión de evento) se debe declarar un domainMethod con nombre descriptivo (ej: remove, purge) en lugar de delete.
method: — omitir si no hay comportamiento de dominio: un command UC puede omitir method: cuando su propósito es puramente de infraestructura (auditoría, logging, saga compensation side-effects) y no modifica el estado del agregado. En ese caso el handler se genera con implementation: scaffold y la lógica va en capa aplicación. NUNCA declarar method: con un nombre que no exista en domainMethods[] del agregado — el build falla con error de validación.
Value Objects
immutable: true — marca el VO como inmutable. El generador aplica List.copyOf() defensivo en listas. Obligatorio para VOs Snapshot (VOs que capturan el estado de una entidad para eventos de dominio). Un VO inmutable no tiene setters y sus colecciones son defensivamente copiadas en el constructor.
Domain Events
published[]:
version (entero ≥1, default 1).
scope: internal|integration|both (default both).
broker: { partitionKey, headers, retry: { maxAttempts, backoff: fixed|exponential, initialMs, maxMs }, dlq: { afterAttempts, routingKey, queueName } }. ⚠️ Claves fuera de la whitelist {partitionKey, headers, retry, dlq} abortan el build con GEN-ERROR. dlq.target no existe — usar routingKey (routing-key RabbitMQ) o queueName (topic DLQ Kafka). broker.retry se parsea pero no genera código — sin efecto en artefactos generados (reservado).
payload[].source — valores válidos hoy por el validador: aggregate | param | timestamp | constant. Campos auxiliares: field (solo con source: aggregate, cuando el nombre del campo del payload difiere del campo en el agregado), param (alias opcional con source: param), value (obligatorio con source: constant). Si el valor es calculado, materializarlo primero como propiedad del agregado y publicarlo con source: aggregate, o resolverlo antes en el handler y pasarlo como source: param. ⚠️ PROHIBIDO: source: auth-context en payloads de eventos (INT-025 — aborta el build). Para incluir el actor que ejecutó la operación: declara el campo en el agregado como readOnly: true, source: authContext y úsalo en el payload con source: aggregate, field: createdBy.
EventMetadata canónica auto-inyectada — NO declarar manualmente eventId, eventType, eventVersion, occurredAt, sourceBc, correlationId, causationId. Si se declaran, el generador los filtra y emite WARN. Los consumidores acceden a ellos vía EventMetadata, no vía payload.
allowHiddenLeak: true — opt-in cuando un campo hidden: true aparece en payload de evento integration o both.
consumed[] — dos formas mutuamente excluyentes:
- Forma A (sin
command:, preferida): declarar solo name + sourceBc + description. El generador localiza automáticamente el UC con trigger.kind: event, consumes: {name} y deriva el binding completo. Usar cuando hay UC formal.
- Forma B (con
command: {UCName}): binding explícito; requiere payload[]. Usar para compensadores de saga o adaptadores legacy sin UC formal. Campos exclusivos de Forma B: queueKey (override del routing-key RabbitMQ), topicKey (override del topic Kafka), filterExpr (expresión Java booleana — si false el listener descarta el mensaje sin error).
sourceBc — validado contra system.yaml (INT-007 si no coincide). Siempre declarar y copiarlo exactamente desde system.yaml#/integrations[].from para el contrato de evento consumido. No usar from como sustituto de sourceBc: el validador de coherencia solo mira sourceBc para reconocer que el publisher está declarado pero aún no diseñado. Si falta sourceBc, un evento de un BC pendiente aparece como INT-007 error en vez de warning.
producer — solo Javadoc; puede coincidir con sourceBc o diferir si hay un intermediario.
- ⚠️
retry y dlq NO se declaran en consumed[] — son configuración de infraestructura. El generador los ignora con GEN-WARN.
Event DTOs — shapes de eventos externos
eventDtos[] — sección de nivel superior para shapes de eventos consumidos de BCs externos.
Genera Java record en {bc}/application/dtos/incoming/ (NO en domain/valueobject/).
- Campos de cada entry:
name (requerido, PascalCase, sin duplicados).
sourceBc (opcional — solo documentación, no valida la integración).
properties[] (requerido, al menos uno): claves name, type, precision, scale, required, description.
- Resolución de tipos en
properties[] (en orden de precedencia):
- Tipo canónico (mismo vocabulario que
aggregates[]).
- Enum declarado en
enums[] del mismo BC.
- Otro
eventDto declarado en eventDtos[] del mismo BC.
- VO declarado en
valueObjects[] del mismo BC.
Decimal requiere precision + scale — igual que en aggregates[].properties[].
- Los listeners (RabbitMQ/Kafka) y UC handlers importan estos records desde
application.dtos.incoming.
- Cuándo usar
eventDtos[] en lugar de valueObjects[]:
- El tipo existe SOLO para transportar datos de un evento de otro BC →
eventDtos[].
- El concepto tiene semántica propia en el dominio de ESTE BC →
valueObjects[].
Errors
code SCREAMING_SNAKE_CASE.
description: — texto libre que se emite como Javadoc en la clase de error generada. Añadir siempre que el código no sea autoexplicativo; ayuda al equipo de Fase 3 a entender cuándo lanzar el error.
httpStatus whitelist: 400, 401, 402, 403, 404, 408, 409, 412, 415, 422, 423, 429, 503, 504.
errorType (override del nombre de la clase de error generada; PascalCase con sufijo Error).
chainable: true — habilita envolver la causa original (la excepción del runtime que disparó el error).
usedFor: auto|manual (default auto). Usar manual para errores que se lanzan manualmente en Fase 3 (sin referencia en ninguna domainRule ni fkValidation) — evita el warning de huérfano del generador.
messageTemplate + args[] — mensaje parametrizado. Placeholders deben coincidir con args[].name.
kind: business|infrastructure. triggeredBy: <identificador completamente cualificado de la clase de excepción del runtime destino> solo válido si kind: infrastructure (clase de excepción de la plataforma, no de domain rule).
- NO declarar
constraintName en errors[] — el validador rechaza la clave (whitelist estricta {code, httpStatus, description, message, title, errorType, chainable, usedFor, messageTemplate, args, kind, triggeredBy}). El nombre del índice único es detalle de infraestructura del almacenamiento: va en aggregates[].domainRules[].constraintName cuando type: uniqueness. El generador empareja errorCode ↔ rule automáticamente.
UseCases — capacidades extendidas
- Commands:
returns: Void | Optional[X] | <VO|projection>.
- Queries:
returns usa {AggregateName}Response para el DTO del agregado, o el nombre de una projection. Escribir solo el nombre del agregado (ej: Category) genera un import inválido → error de compilación en el proyecto destino. Colecciones: Page[{AggregateName}Response], Page[{ProjectionName}].
- No declarar
derived_from ni derivedFrom en useCases — el generador rechaza claves desconocidas en useCases[]. La trazabilidad del UC ya viene dada por su id (UC-XXX-NNN) y por trigger.kind + trigger.operationId (HTTP) o trigger.event (eventos). Para enlazar a reglas de PRD usar rules: [RULE-ID, ...]. derivedFrom solo aplica a artefactos derivados: aggregates[].domainMethods[], repositories[].queryMethods[], aggregates[].properties[] con source: derived y projections[].properties[]. No usar derivedFrom en domainEvents[].payload[]: el validador actual no acepta source: derived en payloads de eventos.
validations[] (array): id, expression (lenguaje natural — describe la condición en términos de negocio; el generador emite // TODO con el texto y Fase 3 lo implementa en Java; nunca escribir código Java aquí), errorCode, description.
lookups[]: param + (aggregate o nestedIn) + errorCode. Mutuamente excluyente con notFoundError. Usar lookups[] cuando hay más de un agregado a cargar o cuando los errores son distintos por agregado; notFoundError cuando solo hay un agregado principal cargado por loadAggregate: true.
input[].source es obligatorio en toda entrada — el generador rechaza con error de build cualquier input sin source declarado. Valores permitidos y cuándo usarlos:
source | Cuándo usar |
|---|
body | Campos del JSON body — commands HTTP (POST/PUT/PATCH). Valor más frecuente en commands. |
query | Parámetros de URL ?campo=valor — queries de filtrado/listado (GET). |
path | Segmentos de URL /{id}. Siempre required: true. Combinar con loadAggregate: true si el input carga el agregado principal. |
authContext | Claims del JWT extraídos del contexto de seguridad. No aparece en el OpenAPI. |
header | Header HTTP personalizado. Requiere headerName. |
multipart | Parte de formulario multipart. Requiere type: File + partName. maxSize opcional como string con unidad ("10MB", no bytes). |
input[] extendido: default (solo si required: false), max (numéricos y listas).
pagination (queries): defaultSize, maxSize, sortable[], defaultSort: { field, direction }. direction debe ser ASC o DESC en mayúsculas — el generador mapea el valor literalmente al identificador del enum de dirección del runtime de la plataforma destino, sin normalización; asc/desc minúsculas hacen abortar el build.
- ⚠️
maxSize está sobrecargado — no confundir los tres usos:
pagination.maxSize (queries) → entero positivo (tamaño máximo de página).
validations[].maxSize sobre una propiedad List[T] → entero positivo (cardinalidad máxima de la lista).
input[].maxSize de una parte multipart File → string con unidad ("10MB", unidades B|KB|MB|GB). Nunca bytes crudos (5242880 → 🔴 BC-024).
fkValidations[].bc — valida existencia de un FK externo. Tres rutas de generación según contexto (ver references/use-cases-design-decisions.md §2): (1) sin bc o mismo BC → repo.findById().isEmpty() inline; (2) BC externo con LRM local (readModel: true) → usa repositorio del LRM; (3) BC externo sin LRM → genera {Bc}ServicePort.java con exists*(). En los casos (2) y (3) exige entrada en integrations.outbound[] para ese BC.
idempotency (solo commands con trigger.kind: http): header, ttl (ISO-8601), storage: cache. ⚠️ Los valores database y redis están deprecados — el generador los rechaza. El único valor soportado es cache; el provider concreto se configura en dsl-springboot.json con la clave cacheProvider. No declararlo en UCs con trigger.kind: event: la idempotencia de mensajes se modela en system.yaml con infrastructure.reliability.consumerIdempotency: true.
authorization: cuatro estrategias combinables — rolesAnyOf[] (RBAC por rol; el generador evalúa realm_access.roles del JWT), permissionsAnyOf[] (RBAC granular con permisos en formato recurso:accion; evalúa el claim permissions), scopesAnyOf[] (OAuth2 Scopes; escribir sin prefijo, el generador añade SCOPE_ automáticamente), ownership: { field, claim, allowRoleBypass } (guarda imperativa en el handler — no genera @PreAuthorize). Cuando se combinan los tres campos de @PreAuthorize, el generador los une con and en orden fijo: scopesAnyOf → rolesAnyOf → permissionsAnyOf. Mutuamente excluyente con public: true. ⚠️ Prerequisito: arch/system/system.yaml debe declarar infrastructure.authServer: true; sin este flag el generador no produce SecurityConfig.java ni ninguna protección Spring Security. Ver guía de decisión en §1.4 y en references/use-cases-design-decisions.md §8.
- Multi-aggregate:
aggregates[] + steps[].{aggregate, method, onFailure.compensate}.
bulk: { itemType, maxItems, onItemError: continue|abort }.
async: { mode: jobTracking|fireAndForget, statusEndpoint }.
- Multipart:
type: File, source: multipart, partName, maxSize, contentTypes[]. ⚠️ Aquí maxSize es un string con unidad ("10MB", unidades B|KB|MB|GB) — nunca un entero de bytes. maxSize: 5242880 falla en el reader de Fase 2 y ahora también en dsl validate (BC-024); usar maxSize: "5MB".
returns: BinaryStream (solo queries).
Range[T], SearchText { fields[] }.
trigger.kind: event con consumes, fromBc, filter (booleano).
description: — texto libre emitido como Javadoc en el Command/QueryObject y en el handler. Aplicar siempre en UCs no triviales para documentar la intención de negocio.
public: true — endpoint HTTP sin JWT. Solo para trigger.kind: http. Añade el path a permitAll() en SecurityConfig y omite @PreAuthorize. Mutuamente excluyente con authorization (si ambos presentes, public: true gana y el generador emite warning).
notes: — texto libre, solo referencia documental (ignorado por el generador). Usar para anotar decisiones de implementación que complementan description pero no son descripción funcional del UC (ej: notes: El slug se genera server-side; el cliente no lo provee.). No confundir con description: description documenta qué hace el UC; notes documenta cómo o por qué se tomó una decisión de diseño.
cacheable: { ttl, keyFields, cacheWhen } — solo para type: query con trigger.kind: http. Genera @Cacheable en el handler y CacheConfig con RedisCacheManager por TTL. Requiere cacheProvider: redis en dsl-springboot.json (el build falla si falta). ttl obligatorio (ISO-8601, ej: PT5M). keyFields[] y cacheWhen[] deben coincidir con nombres en input[].
Repositories — capacidades extendidas
- Operadores whitelist:
EQ, LIKE_CONTAINS, LIKE_STARTS, LIKE_ENDS, GTE, LTE, IN.
filterOn para búsqueda multi-campo: cuando un param debe aplicar LIKE sobre varios campos del agregado, declarar filterOn y operator directamente en el param (no en el método):
params:
- name: search
type: String(200)
required: false
filterOn: [name, sku]
operator: LIKE_CONTAINS
INCORRECTO — filterOn a nivel de método (no soportado por el generador):
filterOn:
- param: search
targets: [name, sku]
operator: LIKE_CONTAINS
- Returns whitelist:
void, Boolean, Int, Long, T, T?, List[T], Page[T], Slice[T], Stream[T].
derivedFrom: <RULE-ID> (ID literal del domainRule, sin prefijo domainRule:) o openapi:{operationId} o implicit. El reader exige que <RULE-ID> exista en aggregates[].domainRules[].id.
- Multi-field:
findByXAndY.
find{Qualifier}By{Field} soportado cuando {Qualifier} resuelve contra el estado del agregado (status o *Status) o soft delete. Ej: findActiveByCustomerId(customerId): Cart? genera status = ACTIVE AND customerId = :customerId. {Field} debe existir en el agregado raíz. Retornos válidos: T?, List[T], Page[T]. Si el qualifier no existe en el enum de estado, el validador falla.
count{Qualifier}By{Field} y exists{Qualifier}By{Field} usan la misma resolución de qualifier. Ej: countActiveByCategoryId, existsNonDeletedByCustomerId.
defaultSort, sortable[], transactional: true.
- Phase 3 opt-ins:
existsBy*, deleteBy*, bulkOperations: true, findByIdForUpdate.
autoDerive: false — opt-out de derivación automática desde domainRules uniqueness.
- ReadModels (
readModel: true): solo findById, findBy{unique}, upsert. Nunca save ni delete.
Projections
- Property keys whitelist:
name, type, required, description, example, serializedName, derivedFrom.
- Sufijos prohibidos en nombres:
Dto, Response, Request, Payload.
- Tipos canónicos extendidos:
Date, Duration, BigInt, Json.
- Aggregates NO pueden ser
type en projections (usar Uuid con composición).
- Projections vacías (
properties: []) prohibidas.
- Inline
returns: en UC sintetiza ${UC}Result.
source: aggregate:<Name> o readModel:<Name> (opcional).
persistent: true — Local Read Model basado en eventos. Genera JPA entity + Spring Data repository + broker listener automáticamente, sin necesidad de declarar use cases explícitos.
- Campos obligatorios:
source: { kind: event, event: <Name>, from: <bc> } + keyBy: <propertyName> + upsertStrategy: lastWriteWins|versionGuarded.
tableName (opcional): nombre de la tabla SQL. Default: proj_{snake_case_de_name}.
eventVersionField (opcional, solo con upsertStrategy: versionGuarded): nombre del campo en properties[] que contiene la versión del evento. Si se omite, el generador busca una propiedad llamada exactamente version; si no existe, el build falla.
additionalSources[] — eventos adicionales que actualizan solo un subconjunto de campos sin insertar filas nuevas. Genera un {Name}On{Event}ProjectionUpdater.java por cada entrada. Cada entry: { kind: event, event: <Name>, from: <bc>, updatesFields: [campo1, campo2] }. Restricciones:
keyBy nunca puede aparecer en updatesFields[].
- Los campos en
updatesFields[] deben estar declarados en properties[].
- El evento referenciado debe estar en
domainEvents.published[] del BC from.
- El evento debe incluir el campo
keyBy en su payload[] en el BC productor. El partial updater lo necesita para localizar la fila con findById. Si no está en el payload, el evento se descarta silenciosamente en runtime con WARN — sin error de build ni de compilación.
- ⚠️ Restricción de tipos en
properties[]: solo se admiten tipos escalares canónicos que mapean a una columna SQL simple. Tipos PROHIBIDOS (el build falla con error inmediato):
Money y cualquier VO del dominio → aplanar: priceAmount: Decimal + priceCurrency: String(3).
- Enums del dominio → usar
String(n) y almacenar el name() del enum.
List[T] → no soportado; serializar como String si es estrictamente necesario.
- Tipos admitidos:
Uuid, String, String(n), Text, Email, Url, Integer, Long, Decimal, Boolean, Date, DateTime.
- AsyncAPI requerido: aunque no hay use cases explícitos, los canales
subscribe de todos los eventos fuente (principal y additionalSources) deben declararse en {bc}-async-api.yaml. El generador los necesita para construir la topología del broker.
Regla de diseño — versionGuarded y versión en el productor
Cuando una projection declara upsertStrategy: versionGuarded, el campo de versión
(eventVersionField o version) debe estar declarado en el payload[] del evento fuente
(source.event) del BC productor. Si no lo está, el guard degenerará silenciosamente a
lastWriteWins en runtime (el generador emitirá INT-027 warn, no error — el código compila).
Verificar antes de escribir el YAML del BC consumidor:
- El BC productor tiene el campo
version: Long (u otro tipo numérico) declarado en el
fields[] (o properties[]) del agregado fuente.
- Ese campo está incluido en el
payload[] del evento source.event en el productor.
- El mismo campo aplica a cada evento en
additionalSources[] que use el mismo
versionGuarded (heredado del padre).
Si el BC productor no incluye la versión en el evento, cambiar a upsertStrategy: lastWriteWins
o pedir que el productor agregue la versión al payload del evento antes de usar versionGuarded.
Integrations — capacidades de plataforma
auth.type — valores y campos auxiliares requeridos por tipo:
none: sin autenticación.
api-key: header (nombre del header, default X-Api-Key) + valueProperty (clave Spring con el secreto).
bearer: valueProperty (clave Spring con el token estático).
oauth2-cc: tokenEndpoint + credentialKey. INT-015: ambos son obligatorios — el build falla si falta alguno.
mTLS: sin campos adicionales. El generador produce MutualTlsSupport.java (compartido) y configura feign.Client.Default con SSLSocketFactory.
internal-jwt: sin campos adicionales. El generador produce InternalJwtPropagator.java (compartido, emitido una vez) — RequestInterceptor que propaga el JWT del SecurityContextHolder al header Authorization: Bearer de cada llamada Feign saliente. Útil para comunicación BC→BC donde el token del usuario original debe fluir por toda la cadena.
resilience schema real: { circuitBreaker: { failureRateThreshold, waitDurationInOpenState, slidingWindowSize, minimumNumberOfCalls, permittedNumberOfCallsInHalfOpenState }, retries: { maxAttempts, waitDuration }, connectTimeoutMs, timeoutMs }. Los valores de tiempo son strings con unidad ("30s", "500ms"), no enteros.
- Precedencia BC→BC:
bc.yaml outbound[name=target].auth/resilience > system.yaml integrations[from=bc, to=target].auth/resilience > system.yaml infrastructure.integrations.defaults.auth.
- Precedencia externo (ACL):
bc.yaml outbound[name=target].auth/resilience > system.yaml externalSystems[name=target].auth/resilience > system.yaml infrastructure.integrations.defaults.auth. Para sistemas externos, integrations[].auth/resilience no es leído — la resiliencia/auth del externo va en externalSystems[].
oauth2-cc y mTLS tienen impacto en infraestructura: oauth2-cc genera oauth2.yaml ×4 entornos + starter-oauth2-client; mTLS genera mtls.yaml ×4 entornos.
- External systems referenciados deben existir en
system.yaml externalSystems[] con operations[] (INT-008/INT-009).
Validación cruzada con AsyncAPI (INT-016..INT-021):
- Cada evento publicado/consumido tiene canal en el AsyncAPI con schema que coincide con
payload[].
- Hidden-field-leak detection: usar
allowHiddenLeak: true para opt-in.
1.4 Guía de Decisión — Cuándo Aplicar Cada Característica
Esta sección responde la pregunta "¿cuándo debo usar X?" para cada característica importante del generador. Aplicar activamente durante el diseño.
Reparto orquestador / worker (Claude Code): esta sección §1.4 (junto con §1.3) es el insumo
que el worker read-only tactical-analyst consume para proponer el modelo de dominio y surfacear
las decisiones (agregado-vs-entidad-vs-VO, LRM vs HTTP). La autoría de los seis artefactos
(Etapas A/B/C, más abajo) la ejecuta el orquestador design-bounded-context en el hilo principal,
nunca el worker. En Copilot ambas cosas corren inline.
concurrencyControl: optimistic — ¿cuándo declararlo?
| Señal en el diseño | Decisión |
|---|
| Múltiples casos de uso pueden modificar el mismo agregado en paralelo (ej: diferentes usuarios actualizan distintos campos del mismo pedido) | ✅ concurrencyControl: optimistic |
El agregado participa como step en una saga con procesos largos (lectura → espera → escritura; la instancia puede cambiar entre la lectura y la escritura) | ✅ concurrencyControl: optimistic |
| Solo el creador puede modificar el agregado, o las modificaciones son secuenciales por diseño | ❌ Omitir |
El agregado es readModel: true (proyección de lectura) | ❌ Omitir — las proyecciones se hidratan vía eventos, no por commands concurrentes |
returns en commands — ¿cuándo declararlo?
| Señal en el OpenAPI | Decisión |
|---|
POST /resources → 201 Created con body del recurso creado | ✅ returns: {AggregateResponse} o returns: {ProjectionName} |
PATCH /resources/{id}/action → 200 OK con estado actualizado | ✅ returns: {AggregateResponse} |
POST /resources → 201 Created + header Location, sin body | ❌ Omitir returns (handler void, controller void) |
PATCH /resources/{id} → 204 No Content | ❌ Omitir returns |
DELETE /resources/{id} → 204 No Content | ❌ Omitir returns |
Command de tipo async (job tracking) | ❌ Omitir returns — el status se consulta por separado |
Regla práctica: Si el OpenAPI declara responses.<2xx>.content.application/json en un command → declarar returns. Si no hay body → omitir.
emits en domainMethods — ¿string o lista?
| Señal | Decisión |
|---|
| La operación produce un único cambio de estado observable | emits: NombreEvento (string) |
La operación coordina múltiples cambios de estado observables en una sola transacción atómica (ej: completar pedido emite OrderCompleted, PaymentSettled, InventoryReleased) | emits: como lista |
| La operación no produce eventos de integración | emits: null |
No fragmentar artificialmente un domainMethod en varios solo para emitir un evento por cada uno. Si la transacción es una sola, el método es uno solo — aunque emita múltiples eventos.
scope en eventos publicados — ¿internal, integration o both?
| Señal | Decisión |
|---|
| El evento lo consumen solo otros use cases dentro del mismo BC (reacción interna) | scope: internal |
| El evento lo consumen solo BCs externos o sistemas de integración | scope: integration |
| El evento lo consumen tanto listeners internos como BCs externos | scope: both (default) |
| Duda → conservador | scope: both — el generador produce ambos listeners y no hay costo por tener ambos |
Nota: scope: internal excluye el evento del AsyncAPI público del BC. Si hay un BC externo en system.yaml que lo consume, cambiar a integration o both.
Proyección persistente (persistent: true) vs readModel: true en agregado — ¿cuál usar?
| Señal | Decisión |
|---|
| El BC necesita leer datos de otro BC para tomar decisiones en tiempo de escritura (ej: validar que el producto existe antes de agregarlo al carrito) | readModel: true en el agregado raíz |
| El BC necesita datos de otro BC solo para consultas de lectura (listados, búsquedas, reportes) | persistent: true en la proyección |
| Los datos del BC fuente cambian frecuentemente y el BC consumidor siempre lee el estado más reciente | readModel: true — el event listener actualiza el agregado vía use case |
| Los datos del BC fuente cambian poco y el BC consumidor los usa para presentación | persistent: true — más liviano, sin use cases de actualización |
| El BC fuente publica múltiples eventos de actualización parcial sobre el mismo concepto | persistent: true con additionalSources[] |
| La consistencia eventual es inaceptable (el BC necesita datos síncronos y garantizados) | Integración HTTP sincrónica via outgoingCalls[] — ni readModel ni persistent |
idempotency en commands HTTP — ¿cuándo declararlo?
| Señal | Decisión |
|---|
| El command puede llegar duplicado por reintentos del cliente (pago, creación de pedido, transferencias) | ✅ idempotency: { header: Idempotency-Key, ttl: PT24H, storage: cache } |
| El command es consumido desde un evento de broker (no HTTP) | ❌ Omitir — la idempotencia de eventos se gestiona a nivel de sistema con consumerIdempotency: true en system.yaml |
| El command es un query o una lectura | ❌ Omitir |
| El command es idempotente por naturaleza (PATCH que solo actualiza un campo simple) | ❌ Omitir — no hay ganancia y agrega complejidad |
Usar storage: cache (único valor soportado). El provider concreto de caché (Redis, Caffeine, etc.) se configura en dsl-springboot.json con la clave cacheProvider — no en el YAML de diseño.
Regla dura del generador y del validador de diseño: useCases[].idempotency solo es válido en commands HTTP. Si el UC tiene trigger.kind: event, omitir el bloque completo aunque exista eventId; usar consumerIdempotency: true en system.yaml para deduplicación de mensajes.
Diseño de use cases con trigger.kind: event cuando consumerIdempotency: true
Cuando el sistema activa infrastructure.reliability.consumerIdempotency: true en system.yaml,
el generador produce una guardia de deduplicación (IdempotencyGuard) que registra el eventId
en la tabla processed_event antes de despachar el use case, en una transacción separada
(REQUIRES_NEW). Esto tiene una consecuencia crítica de diseño:
Si el use case falla después de que IdempotencyGuard.tryRecord() confirma:
- La fila
(handlerId, eventId) persiste en processed_event aunque el use case no completó.
- El broker reentregará el mensaje, pero la guardia lo descartará silenciosamente.
- El use case no se ejecutará en el siguiente reintento.
Implicación directa para el diseño en Paso 2:
| Señal en el use case | Acción recomendada en el diseño |
|---|
| El UC llama a sistemas externos (HTTP, BBDD) que pueden fallar transitoriamente | Marcar implementation: scaffold y documentar en flows.md que el UC debe ser tolerante a fallos (ej: verificar estado antes de mutar, usar operaciones idempotentes) |
| El UC escribe en múltiples repositorios | Declarar el UC con implementation: scaffold — la Fase 3 debe asegurar que cada escritura sea idempotente o que la primera escritura exitosa sea suficiente |
| El UC es un paso de saga y su fallo dejaría el sistema en estado inconsistente | Declarar explícitamente en {bc-name}-flows.md el comportamiento esperado ante fallo permanente y si existe compensación manual |
El UC es naturalmente idempotente (ej: upsert de una proyección, actualización de estado que ya verifica el estado actual) | No requiere acción especial — el diseño ya garantiza que re-ejecutar produce el mismo resultado |
En resumen: con consumerIdempotency: true, el primer intento fallido de un use case
disparado por evento es potencialmente definitivo. Los use cases con trigger.kind: event
deben diseñarse para que su lógica de negocio sea internamente resiliente, no dependiente
de una nueva entrega del mismo mensaje para corregir un fallo previo.
source: param vs valor calculado en event payload — la frontera crítica
| Señal | Decisión |
|---|
El valor es calculable por el propio agregado usando solo sus campos internos (ej: total = unitPrice * quantity, fullName = firstName + lastName) | No usar source: derived en payloads de eventos. Materializarlo como propiedad del agregado y emitirlo con source: aggregate, o resolverlo antes y pasarlo como source: param |
| El valor requiere consultar otra entidad, proyección o servicio externo para resolverse | source: param — el handler lo resuelve antes de llamar al domainMethod y lo pasa como parámetro |
El valor es el resultado de un outgoingCall declarado en el UC | source: param — el resultado se pasa vía bindsTo en outgoingCalls[] |
El valor es un parámetro del domainMethod que no existe como propiedad del agregado (ej: lines: List[OrderLineSnapshot] pasado a Order.create()) | source: param — el parámetro existe en domainMethods[].signature pero no en properties[] del agregado |
| El valor es una constante fija en tiempo de diseño | source: constant + value: "{literal}" — obligatorio declarar value: |
| El campo existe en el agregado y el nombre del payload coincide | source: aggregate (o simplemente omitir source: — es el valor por defecto) |
⚠️ Regla crítica de consistencia: los campos con source: aggregate deben existir como entradas en properties[] del agregado que emite el evento (el que declara el domainMethod con emits:). Las colecciones de entidades hijas (ej: CartItem[], OrderLine[]) no son propiedades del agregado — el validador las rechazará. Si el payload necesita llevar una colección de entidades, el patrón correcto es:
- Definir un VO snapshot (ej:
OrderLineSnapshot) que capture los datos necesarios.
- Declarar ese VO como parámetro del domainMethod que crea/emite el evento.
- Usar
source: param en el payload — el handler construye los snapshots antes de llamar al domainMethod.
Regla de asignación de emits:: un evento solo debe declararse en el domainMethod del agregado cuyos properties[] cubren todos los campos source: aggregate del payload. Si el evento lleva datos de otro agregado creado en la misma transacción (ej: OrderPlaced lleva total y paymentMethod que son propiedades de Order, no de Cart), el emits: debe estar en el domainMethod del agregado propietario de esos datos (Order.create()), no en el que inicia la transacción (Cart.checkout()).
⚠️ Error crítico (INT-026): si un campo del payload usa source: param, el parámetro DEBE existir en aggregates[].domainMethods[{method}].params[]. Si no existe, el generador emite null silenciosamente — el evento lleva un campo nulo sin error de build ni de compilación.
⚠️ PROHIBIDO: source: auth-context está prohibido en payloads de eventos (INT-025 — aborta el build). Para capturar el actor que ejecutó la acción, declara el campo con readOnly: true, source: authContext en el agregado y resuélvelo en el payload con source: aggregate, field: createdBy.
consumed[] Forma A vs Forma B — ¿cuándo declarar command:?
Forma A (sin command:) — El generador localiza automáticamente el UC con trigger.kind: event, consumes: {EventName}:
| Señal | Decisión |
|---|
Hay un UC formal en useCases[] con trigger.kind: event para este evento | ✅ Forma A — solo declarar name, sourceBc, description |
| El evento activa exactamente un UC (el caso más común) | ✅ Forma A |
Forma B (con command: {UCName}) — binding explícito; requiere payload[]:
| Señal | Decisión |
|---|
| El handler procesa el evento sin UC formal (ej: paso de compensación de saga, adaptador legacy) | ✅ Forma B |
| El routing-key o topic real difiere de la convención derivada del nombre del evento | ✅ Forma B + queueKey (RabbitMQ) o topicKey (Kafka) |
| Solo algunos mensajes del canal deben procesarse según contenido del payload | ✅ Forma B + filterExpr: "{expresión Java booleana}" |
sourceBc vs producer: sourceBc es validado por el generador contra system.yaml (INT-007 si no coincide) — siempre declarar. producer es solo Javadoc en el listener — opcional, útil cuando el publicador efectivo es diferente al BC registrado en system.yaml (ej: un sistema intermediario).
broker hints en eventos publicados — ¿cuándo declarar cada campo?
| Campo | Cuándo declararlo |
|---|
partitionKey: {field} | Solo en Kafka: cuando el orden de eventos del mismo recurso importa (ej: orderId garantiza que todos los eventos de un pedido van al mismo partition y se procesan en orden) |
headers: {k: v} | Cuando el consumidor necesita filtrar mensajes sin deserializar el body (ej: eventType, tenantId) |
retry: {maxAttempts, backoff} | ⚠️ Sin efecto en los artefactos generados — el generador lo parsea pero no produce código de retry. Configurar reintentos en system.yaml |
dlq: {afterAttempts, routingKey, queueName} | Cuando el BC es responsable de configurar el dead-letter. routingKey (RabbitMQ) o queueName (Kafka). Se propaga a la configuración del consumidor que lo declare |
⚠️ Whitelist estricta: solo partitionKey, headers, retry, dlq son claves válidas en broker:. Cualquier clave desconocida aborta el build (GEN-ERROR). broker.dlq.target no existe — usar routingKey o queueName según el broker.
Estrategia de authorization — ¿cuándo usar cada campo?
| Campo | Pregunta que responde | Expresión generada | Caso típico |
|---|
rolesAnyOf[] | ¿Qué función tiene el usuario? | hasAnyRole('ADMIN') | Backoffice/admin con roles estables (< 5) |
permissionsAnyOf[] | ¿Qué operación puede realizar el usuario? | hasAnyAuthority('orders:cancel') | RBAC maduro — muchos roles, permisos granulares |
scopesAnyOf[] | ¿Qué puede hacer este token / cliente? | hasAnyAuthority('SCOPE_catalog:write') | M2M, APIs públicas, clientes OAuth2 externos |
ownership | ¿Es el usuario el propietario del recurso? | guarda imperativa en handler | Portal de autoservicio — usuario gestiona solo sus recursos |
Convención de permisos (permissionsAnyOf): formato recurso:accion en minúsculas con guiones para recursos compuestos. Ejemplos: orders:cancel, catalog:write, user-profile:update. El generador mapea directamente a hasAnyAuthority(...).
Convención de scopes (scopesAnyOf): escribir sin prefijo SCOPE_. El generador lo añade: catalog:write → hasAnyAuthority('SCOPE_catalog:write').
Orden en SpEL cuando se combinan múltiples campos: siempre scopesAnyOf → rolesAnyOf → permissionsAnyOf, unidos con and.
Señales para elegir la combinación:
| Escenario | Estrategia recomendada |
|---|
| Endpoint llamado por otro servicio (M2M) o cliente OAuth2 externo | Solo scopesAnyOf |
| Admin / operator de backoffice con roles estables | Solo rolesAnyOf |
| Muchos tipos de usuario con permisos distintos sobre el mismo recurso | Solo permissionsAnyOf |
| API que sirve tanto M2M como usuarios humanos con rol | scopesAnyOf + rolesAnyOf |
| Portal de cliente: usuario solo actúa sobre sus propios recursos | rolesAnyOf + ownership |
| Admin puede sobrescribir la restricción de propiedad | ownership + allowRoleBypass: [ROLE_ADMIN] |
| Endpoint sin autenticación (catálogo público, health check) | public: true — mutuamente excluyente con authorization |
ownership — requisitos de generación:
- Al menos un
input[] debe tener loadAggregate: true, o debe existir un lookups[] que cargue el agregado. El generador compara aggregate.{field} con currentUserClaim(claim) en el handler.
allowRoleBypass[] acepta roles con o sin prefijo ROLE_; el generador normaliza.
- No genera
@PreAuthorize — la guarda es imperativa en el body del handler, no en la anotación.
authorization:
rolesAnyOf: [ROLE_CUSTOMER, ROLE_ADMIN]
ownership:
field: customerId
claim: sub
allowRoleBypass: [ROLE_ADMIN]
authorization:
scopesAnyOf: [catalog:write]
rolesAnyOf: [ROLE_CATALOG_MANAGER]
Para criterios detallados con todos los casos edge, ver references/use-cases-design-decisions.md §8.
fkValidations[] — ¿cuándo declararlo y qué ruta elegir?
Regla general: Declarar fkValidations[] siempre que el command recibe un UUID que referencia un recurso de otro agregado y la existencia del referenciado es invariante del caso de uso.
Ruta según contexto:
| Contexto | Ruta | Cómo expresarlo |
|---|
| FK referencia a un agregado del mismo BC | Inline repo | Sin campo bc — el generador inyecta repo.findById().isEmpty() |
FK referencia a un BC externo con LRM local (readModel: true en el mismo BC) | LRM query | Con campo bc: {bc-name} — el generador usa el repo del readModel |
| FK referencia a un BC externo sin LRM | ServicePort | Con campo bc: {bc-name} + entrada en integrations.outbound[] — el generador genera {Bc}ServicePort con exists*() |
Si la existencia del FK no es una invariante (el recurso referenciado puede haber sido borrado y el comportamiento es acceptable), no declarar fkValidations[] — documentar en {bc-name}-flows.md el comportamiento esperado.
notFoundError vs lookups[] — ¿cuál usar?
| Situación | Decisión |
|---|
Solo hay un agregado principal cargado via loadAggregate: true | notFoundError: [ENTITY_NOT_FOUND] |
| Se cargan múltiples entidades distintas (ej: el pedido Y cada línea del pedido por separado) | lookups[]: [{aggregate: X, param: xId, errorCode: X_NOT_FOUND}, ...] |
| Los errores de "no encontrado" son distintos según el tipo de entidad | lookups[] — permite errorCode distinto por entidad |
| Solo hay un agregado pero se necesita un errorCode específico distinto al genérico | notFoundError: [SPECIFIC_CODE] |
notFoundError y lookups[] son mutuamente excluyentes en el mismo UC. Usar uno u otro, nunca ambos.
validations[] en useCases — ¿cuándo declarar y qué escribir?
Usar validations[] cuando existe una regla de negocio cross-field o contextual que no puede expresarse como domainRule en el agregado porque depende de los valores de entrada del command específico (no del estado persistido del agregado).
validations:
- id: VAL-001
expression: "El precio de venta no puede ser menor que el precio de costo"
errorCode: PRICE_BELOW_COST
expression siempre en lenguaje natural — describe la condición de negocio, nunca código Java.
- No duplicar una
domainRule existente como validation[] — el generador ya genera el guard del domainRule.
- Si la validación se puede expresar como
domainRule en el agregado → prefiere domainRule (más cercana al dominio).
implementation: scaffold — ¿cuándo decidir y qué documentar?
| Condición | Decisión |
|---|
Todos los params del domainMethod resolvibles desde input[] | full |
Al menos un param del domainMethod no resolvible desde input[] | scaffold |
Evalúa crossAggregateConstraint (requiere consulta a otro agregado) | scaffold |
Aplica sideEffect (crea entidad adicional como historial, log, audit trail) | scaffold |
UC con transición de estado condicional (condition: RULE-ID) | scaffold |
UC de tipo query — siempre | full |
Corolario obligatorio: Todo UC con implementation: scaffold debe tener al menos un flujo dedicado en {bc-name}-flows.md con happy path concreto (datos de ejemplo reales, no abstractos) + orden de evaluación de reglas + efectos secundarios.
autoDerive: false en repositories — ¿cuándo declararlo?
autoDerive: false desactiva la derivación automática de métodos findBy{Campo} desde domainRules[].type: uniqueness. Usar cuando:
- La regla de unicidad tiene lógica de validación especial que no encaja en el método derivado estándar.
- Ya se declaró manualmente un método
findBy{Campo} con signatura diferente a la que el generador derivaría.
- La unicidad se valida en la DB via constraint y no se requiere método de consulta previa.
Por defecto (sin autoDerive: false) el generador deriva el método automáticamente.
allowHiddenLeak: true en eventos — ¿cuándo justificado?
Solo justificado cuando:
- El campo
hidden: true contiene datos que el BC consumidor necesita para procesar el evento (ej: un hash de contraseña para sincronización entre BCs de identidad).
- El consumidor es un sistema interno controlado (no un sistema externo público).
- Existe documentación en
{bc-name}-spec.md que justifica la decisión de seguridad.
En la mayoría de los casos, si un campo es hidden: true es porque no debe salir del BC. Cuestionar el diseño antes de usar allowHiddenLeak: true.
bulk vs múltiples commands individuales — ¿cuándo usar bulk?
| Señal | Decisión |
|---|
| El actor envía una lista de entidades para procesar en una sola llamada HTTP (importación masiva, activación en lote) | ✅ bulk: { itemType: X, maxItems: N, onItemError: continue } |
| Las entidades del lote deben procesarse en una transacción atómica (todas o ninguna) | ✅ bulk: { ..., onItemError: abort } |
| El actor procesa entidades una por una o el tamaño del lote siempre es 1 | ❌ Command individual sin bulk |
| El procesamiento del lote puede tomar más de unos segundos | Considerar async: { mode: jobTracking } + bulk combinado |
async (jobTracking/fireAndForget) — ¿cuándo declararlo?
| Señal | Decisión |
|---|
| El command inicia un proceso que puede tardar segundos o minutos (ej: generación de reporte, importación grande) | ✅ async: { mode: jobTracking, statusEndpoint: /jobs/{jobId} } |
| El command dispara un proceso en segundo plano que el actor no necesita rastrear | ✅ async: { mode: fireAndForget } |
| El command es transaccional y responde en menos de 1-2 segundos | ❌ Command síncrono sin async |
errorType override — ¿cuándo usar?
Usar errorType solo cuando el nombre derivado automáticamente del code es ambiguo, muy largo, o no sigue la convención del proyecto destino:
- code: PRODUCT_NOT_FOUND
- code: CAT_001
Por defecto (sin errorType), el generador deriva el nombre mecánicamente de code y el resultado suele ser correcto para codes en SCREAMING_SNAKE_CASE descriptivos.
public: true en use cases — ¿cuándo declararlo?
| Señal | Decisión |
|---|
| El endpoint es de solo lectura y no expone datos de usuario ni personalizados (catálogo público, landing page, lookup de países) | ✅ public: true |
| El endpoint es un webhook receiver que autentica por firma de payload, no por JWT | ✅ public: true (la verificación de firma se implementa manualmente en Fase 3) |
| El endpoint retorna datos distintos según el usuario autenticado | ❌ Requiere JWT — omitir public |
| El endpoint ejecuta cualquier escritura (command) | ❌ Omitir — los commands siempre requieren identidad del actor |
| El endpoint está detrás de un BFF que ya valida el JWT | ❌ Omitir — el BFF delega el JWT al microservicio |
Nota de seguridad: public: true elimina la verificación de JWT pero no desactiva rate limiting ni IP filtering (configurados fuera del generador). Solo válido para trigger.kind: http; en trigger.kind: event no tiene efecto.
cacheable — ¿cuándo declararlo en un query?
| Señal | Decisión |
|---|
| El query retorna datos estáticos o de muy baja frecuencia de cambio (catálogos, árboles de categorías, listas de países) | ✅ cacheable: { ttl: PT1H } |
| El query retorna detalles de una entidad por ID (lectura frecuente, escritura poco frecuente) | ✅ cacheable: { ttl: PT5M, keyFields: [entityId] } |
| El query retorna el estado en tiempo real de un proceso activo (pedido en tránsito, saldo de cuenta) | ❌ Omitir — los datos cambian tan frecuentemente que la caché introduce inconsistencias inaceptables |
| El query retorna datos personalizados por usuario (carrito, perfil, wishlist) | ❌ Omitir — la caché compartiría datos entre usuarios o requeriría keys complejas |
La infraestructura no tiene Redis (cacheProvider: redis en dsl-springboot.json) | ❌ Omitir — el build falla |
cacheWhen: usar solo cuando el query tiene parámetros opcionales cuya ausencia cambia radicalmente el scope del resultado (ej: SearchProductsByCategory solo se cachea cuando categoryId no es nulo). Solo válido en type: query. Declarar cacheable en un command aborta el build.
Tipo de retorno en repositories[] — ¿cuál elegir?
| Necesidad | returns | Cuándo |
|---|
| Listado paginado con total de elementos (UI de tabla con páginas y contador) | Page[T] | Requiere param pageable o par page+size |
| Listado paginado sin total (scroll infinito, cursor paginado) | Slice[T] | Más eficiente: no ejecuta COUNT(*) |
| Procesamiento incremental de millones de filas (exports, batch) | Stream[T] | Scroll interno; no cargar todo en memoria |
| Lista completa sin paginación (volumen máximo pequeño y conocido por diseño) | List[T] | Peligroso si el volumen puede crecer |
| Un registro opcional (puede no existir) | T? | Siempre en findBy* que puede no encontrar |
| Un registro obligatorio (su ausencia es error de programación, no de negocio) | T | Raro — preferir T? con orElseThrow en el handler |
| Verificar existencia sin cargar el objeto (guard, deduplicación) | Boolean | Para existsBy* |
| Contar registros (para validaciones de límite o estadísticas) | Long o Int | Long para conteos grandes, Int para cardinalidades pequeñas |
Regla anti-Stream[T]: no usar en handlers que terminan en un response HTTP — Spring cierra la sesión JPA antes de que el stream se consuma completamente. Usar solo en procesos batch que consumen el stream dentro de la misma transacción.
findByIdForUpdate — ¿cuándo declararlo?
| Señal | Decisión |
|---|
| El handler lee el agregado y luego lo modifica, y la concurrencia es alta (múltiples transacciones sobre el mismo registro) | ✅ findByIdForUpdate (Phase 3 opt-in — genera @Lock(PESSIMISTIC_WRITE)) |
El agregado ya declara concurrencyControl: optimistic | ❌ Redundante — el @Version detecta conflictos; agregar lock pesimista solo aumenta el overhead |
| El proceso es una saga donde el conflicto debe detectarse antes de ejecutar la acción (no después de fallar) | ✅ findByIdForUpdate — el lock pesimista previene el conflicto; el optimista lo detecta post-hecho |
| La probabilidad de colisión es baja (la mayoría de los escenarios de negocio) | ❌ concurrencyControl: optimistic — menor overhead, más escalable |
Comparativa: optimista (@Version) detecta conflictos al hacer flush, bajo overhead, ideal para baja colisión. Pesimista (findByIdForUpdate) bloquea la fila, necesario cuando el rollback y reintento son inviables o muy costosos.
description en errors y use cases — ¿cuándo añadir?
| Elemento | ¿Añadir description? | Efecto en el generador |
|---|
Error con código críptico o no autoexplicativo (ej: RULE_VLD_004) | ✅ Siempre | Genera Javadoc en la clase de error |
Error con código descriptivo (PRODUCT_NOT_FOUND) | ✅ Recomendado si el motivo exacto no es obvio del código | Genera Javadoc |
| UC con lógica no trivial o múltiples precondiciones de negocio | ✅ Siempre | Genera Javadoc en el Command/Query y en el handler |
| UC simple tipo CRUD sin reglas de negocio especiales | Opcional — el name ya documenta la intención | — |
description en los errores y use cases se convierte en Javadoc en el código generado. Es la única documentación automática del comportamiento; evita que el equipo de Fase 3 tenga que leer el YAML para entender cada clase.
messageTemplate + args vs message — ¿cuándo parametrizar el mensaje de error?
| Situación | Decisión | Ejemplo |
|---|
| El mensaje de error es estático (no incluye datos del request) | Usar message: "texto fijo". No declarar args. | "The product cannot be activated." |
| El mensaje necesita incluir un valor del request (el SKU duplicado, el ID no encontrado, el límite excedido) | Usar messageTemplate con {placeholder} + args. | "A product with SKU '{sku}' already exists." |
| El error tiene múltiples valores dinámicos de tipos distintos | Usar messageTemplate con múltiples placeholders + un entry en args por cada uno. | "Order {orderId} exceeds the daily limit of {limit} for customer {customerId}." |
Regla: cuando el usuario necesita saber qué valor causó el problema para poder corregirlo, usar messageTemplate + args. Si el mensaje genérico es suficiente, usar message.
Tipos válidos para args[].type: String, UUID, Integer, Long, BigDecimal, Boolean, LocalDate, LocalDateTime.
- code: ORDER_EXCEEDS_DAILY_LIMIT
httpStatus: 422
messageTemplate: "Order {orderId} exceeds the daily limit of {limit} for customer {customerId}."
args:
- name: orderId
type: UUID
- name: limit
type: BigDecimal
- name: customerId
type: UUID
- code: CART_IS_EMPTY
httpStatus: 422
message: The cart must have at least one item before checkout.
chainable: true — ¿cuándo preservar la causa original?
| Situación | Decisión |
|---|
| El error representa una falla de infraestructura (timeout, conexión, BD, servicio externo) | chainable: true — el adaptador hace new MiError(causa) y el stack trace de la causa aparece en los logs |
| El error es de dominio puro (precondición, estado, unicidad) | chainable: false (default) — no hay causa original que preservar |
Por qué importa: sin chainable: true, el HandlerExceptions no puede recibir la causa original como parámetro. El stack trace de la FeignException o DataAccessException se pierde en los logs, dificultando el debugging en producción.
Regla: todo error con kind: infrastructure debe tener chainable: true. Es válido también en errores kind: business que envuelven excepciones técnicas (ej: un timeout interno tratado como error de dominio).
kind: infrastructure + triggeredBy — ¿cuándo mapear excepciones JVM a errores de dominio?
| Situación | Decisión |
|---|
| Una excepción JVM específica (Feign, Hibernate, JPA) puede ocurrir en múltiples puntos del BC y siempre debe producir la misma respuesta HTTP | kind: infrastructure + triggeredBy: <FQN> — el @ExceptionHandler global atrapa la excepción en cualquier handler |
La excepción es una condición de carrera que complementa la proactive guard (ej: DataIntegrityViolationException en inserts concurrentes) | kind: infrastructure + triggeredBy: org.springframework.dao.DataIntegrityViolationException — manejo reactivo de lo que la validación proactiva no pudo prevenir |
| La excepción solo ocurre en un punto específico y el código de Fase 3 la captura manualmente | kind: business (default) + usedFor: manual — no registrar un @ExceptionHandler global |
Restricciones:
triggeredBy solo es válido con kind: infrastructure. El build falla si aparece sin él.
- El mismo
triggeredBy class no puede mapearse a dos errores distintos en el mismo BC.
- Siempre añadir
chainable: true junto con kind: infrastructure.
- code: CONCURRENT_UPDATE_CONFLICT
httpStatus: 409
kind: infrastructure
triggeredBy: org.springframework.dao.DataIntegrityViolationException
chainable: true
description: >
Handles concurrent insert conflicts when the proactive uniqueness guard
loses a race condition. The DB constraint is the last line of defense.
- code: PAYMENT_GATEWAY_TIMEOUT
httpStatus: 504
kind: infrastructure
triggeredBy: feign.RetryableException
chainable: true
message: The payment gateway timed out. Please retry the operation.
usedFor: manual — ¿cuándo declarar un error sin referencia en el YAML?
| Situación | Decisión |
|---|
El error se lanza desde un domainRule, notFoundError, lookups[], fkValidations[] o validations[] | usedFor: auto (default) — el generador valida que el código esté referenciado |
| El error se lanza exclusivamente desde código manual de Fase 3 (saga orchestrator, adaptador, compensación) sin ninguna declaración declarativa en el YAML | usedFor: manual — suprime la advertencia de "error huérfano" |
Señal de diseño: si muchos errores necesitan usedFor: manual, revisar si faltan domainRules que los debería referenciar. El caso de uso más legítimo para usedFor: manual es la lógica de saga/compensación, que por definición es imperativa y no puede expresarse en reglas declarativas.
- code: SAGA_ROLLBACK_FAILED
httpStatus: 503
usedFor: manual
description: >
Thrown manually from the saga compensator when the rollback step also fails.
There is no domainRule that references this code — it is thrown imperatively
in the infrastructure saga orchestrator code written in Phase 3.
bulkOperations: true en repositories — ¿cuándo declararlo?
| Situación | Decisión |
|---|
Un use case tiene bulk: declarado (importación masiva, sincronización de catálogos) | bulkOperations: true — el puerto de dominio expone saveAll, findAllById y count |
| El BC necesita contar todos los registros del agregado (sin filtros) en algún flujo de negocio | bulkOperations: true — expone count() en el puerto |
| Los use cases son CRUD estándar uno a uno y no hay flujos de importación/lote | omitir bulkOperations (default: no se exponen los métodos bulk en el puerto) |
Nota técnica: saveAll y findAllById ya están disponibles en la capa JPA (heredados de JpaRepository). bulkOperations: true los promueve al puerto de dominio (interfaz), haciéndolos accesibles desde los handlers de aplicación. Sin este flag, los handlers no pueden llamarlos aunque la capa JPA los tenga.
repositories:
- aggregate: Product
bulkOperations: true
queryMethods vs methods en repositories — ¿dónde va cada método?
| Tipo de método | Sección correcta | Ejemplo |
|---|
| Listado con filtros múltiples (cualquiera de ellos puede ser opcional) | queryMethods | list(status?, search?, page) |
| Búsqueda por múltiples campos con texto libre | queryMethods | searchProducts(term?, minPrice?, maxPrice?, page) |
Lookup por un único campo único (findBy{Campo}) | methods | findBySku(sku): Product? |
Lookup puntual por estado + campo (find{Qualifier}By{Field}) | methods o queryMethods según uso | findActiveByCustomerId(customerId): Cart? |
Lookup por PK (findById) | methods | findById(id): Product? |
Verificación de existencia (existsBy*) | methods | existsByEmail(email): Boolean |
Conteo de dependencias (countBy*, countNonDeletedBy*) | methods | countNonDeletedByCategoryId(categoryId): Int |
Persistencia (save) | methods | save(entity): void |
Eliminación (delete) | methods | delete(id): void |
Regla de oro: si el método tiene un parámetro de filtro que puede variar en la query string de un GET, va en queryMethods. Si es un lookup puntual por un único campo o una operación de escritura, va en methods.
Error frecuente: poner métodos list o search* en methods. Estos métodos necesitan la generación de @Query JPQL que solo ocurre en queryMethods. El generador no genera @Query para métodos en methods.
Error frecuente — countBy* en el agregado equivocado: un método de repositorio debe declararse siempre bajo el agregado cuyas instancias se cuentan o buscan, no bajo el agregado que actúa como parámetro de filtro. Ejemplo: countByCategoryId(categoryId): Long cuenta Products, por lo que va en repositories[aggregate: Product].methods[], no en repositories[aggregate: Category].methods[]. Poner el método en el agregado incorrecto genera la llamada en el handler con el repositorio equivocado y produce un error de compilación (cannot find symbol).
defaultSort + sortable[] en queryMethods — ¿cuándo declarar ordenación?
| Situación | Decisión |
|---|
El retorno del queryMethod es List[T] (sin paginación, sin Pageable) | Declarar defaultSort: {field, direction} — fija el orden en la @Query JPQL |
El retorno es Page[T] (con Pageable) y la UI siempre ordena por el mismo campo | Omitir defaultSort — el orden lo controla Pageable.sort en runtime desde el cliente |
El retorno es Page[T] y el cliente puede elegir el campo de orden desde la API | Declarar sortable: [campo1, campo2] — el generador valida que el campo de sort del Pageable esté en la lista permitida |
| El método devuelve un único elemento o un Boolean/Int | No aplica defaultSort ni sortable[] |
Para List[T]: defaultSort es especialmente importante porque la query no tiene ORDER BY implícito. Sin defaultSort, el orden de los resultados es no determinístico (depende del motor de BD y el plan de ejecución).
Para Page[T]: sortable[] actúa como allowlist de campos de ordenación que el cliente puede pasar en el parámetro sort del Pageable. Si no se declara, el generador no restringe los campos de sort (cualquier campo del JPA entity es válido).
queryMethods:
- name: list
params:
- name: status
type: ProductStatus
required: false
- name: page
type: PageRequest
required: true
returns: "Page[Product]"
defaultSort:
field: createdAt
direction: DESC
sortable:
- createdAt
- name
- priceAmount
- name: listFeaturedProducts
params:
- name: categoryId
type: Uuid
required: true
returns: "List[Product]"
defaultSort:
field: sortOrder
direction: ASC
Fase 2: Clarificación con el Usuario
Siempre preguntar antes de asumir cuando haya ambigüedad. Usar vscode_askQuestions (o en texto directo)
con preguntas agrupadas en una sola llamada.
Cuándo preguntar obligatoriamente
| Situación | Pregunta recomendada |
|---|
| El BC tiene integraciones con sistemas externos no detalladas | ¿Qué operaciones específicas se realizan contra ese sistema? |
| Un agregado del Paso 1 parece demasiado grande | ¿[Entidad X] tiene identidad propia o siempre vive dentro de [Root]? |
| No está claro el ciclo de vida de un agregado | ¿Cuáles son los estados posibles de [Agregado]? |
| Una propiedad puede ser requerida u opcional según el contexto | ¿[Campo] es siempre requerido o solo en algunos flujos? |
| Los casos de uso no están claros por el contexto de negocio | ¿Quién puede ejecutar [acción] y bajo qué condiciones? |
| El BC consume eventos pero no está claro el impacto | ¿Qué hace exactamente el BC cuando recibe [Evento]? |
Cuándo inferir sin preguntar
- Auditoría del agregado root (
auditable: true) → siempre presente; el generador inyecta createdAt y updatedAt automáticamente
- Identificador único del agregado root (
id: Uuid) → siempre presente
- Tipos canónicos evidentes (precio →
Money, email → Email) → usar directamente
- Reglas de negocio que son invariantes universales del dominio
Fase 3: Diseño del Dominio — {bc-name}.yaml (v1)
Esta fase produce el yaml v1 — el núcleo del dominio sin las secciones enriquecidas.
Las secciones useCases, repositories y errors se agregan en la Etapa C (Fase 9).
Leer references/bc-yaml-schema.md para el schema completo antes de escribir.
Leer references/bc-yaml-guide.md para ejemplos anotados de cada sección, distinción condition vs rules, flags de agregado (auditable, softDelete, readModel), convenciones de naming y relación con los demás artefactos del Paso 2.
Leer references/canonical-types.md para la tabla de tipos y sus validaciones implícitas.
Leer references/validation.md para el vocabulario completo de validations — cuándo usarlas, qué constraints están disponibles y cómo se traducen por plataforma. Aplicar validations en properties[] siempre que el dominio imponga restricciones que el tipo canónico no captura solo (rangos, patrones, mínimos de longitud, restricciones temporales).
Leer references/relationship-types.md para las reglas de relaciones.
Leer references/use-cases-design-decisions.md antes de diseñar la sección useCases[] — contiene los criterios de decisión para cada mecanismo de los use cases.
3.1 Estructura del archivo (v1)
El {bc-name}.yaml v1 se compone de estas secciones en orden:
bc: → nombre exacto del BC (igual que en system.yaml)
type: → core | supporting | generic
description: → propósito en 1-2 oraciones (inglés)
enums: → enums con valores y transiciones si aplica
valueObjects: → VOs con sus propiedades tipadas
eventDtos: → shapes de eventos de BCs externos (application.dtos.incoming)
aggregates: → agregados con entidades, propiedades y reglas
integrations: → integraciones sincrónicas (inbound y outbound)
domainEvents: → eventos publicados y consumidos
Nota sobre domainRules en v1: Incluir id y description. El campo type y errorCode
se completan en la Etapa C cuando el agente puede clasificarlas con contexto completo.
Sin embargo, si el tipo es inequívoco (ej: uniqueness, terminalState), puede incluirse en v1.
3.2 Reglas de diseño de Enums
Para enums que representan ciclos de vida (estados de un agregado):
- Expandir cada valor con
transitions[]
- Cada transición declara:
to, triggeredBy, condition, rules[], emits
emits: null si la transición no genera evento
triggeredBy usa siempre el formato largo: UC-{ABREV}-{NNN} NombreUC (ej: UC-PRD-004 ActivateProduct). El formato corto (solo el UC-ID sin nombre) dificulta la trazabilidad y el checker B1 del refinamiento lo señalará como sugerencia de normalización.
condition debe ser siempre un RULE-ID o la literal none — NUNCA texto libre descriptivo.
- Correcto:
condition: CAT-RULE-003 ó condition: none
- Incorrecto:
condition: "Product's category must be in ACTIVE status"
Para enums que son clasificaciones simples (roles, tipos, categorías):
- Usar formato corto:
value + description
- Sin sección
transitions
3.3 Reglas de diseño de Value Objects
Un VO es apropiado cuando:
- El valor tiene semántica de negocio más allá del tipo primitivo (
Email ≠ String)
- El valor es siempre inmutable y se reemplaza completo (nunca se modifica una parte)
- El valor puede aparecer en múltiples entidades del BC con la misma semántica
Propiedades de un VO siempre tienen tipos canónicos — nunca referencian otros VOs o
Enums de forma anidada excepto si es genuinamente un tipo compuesto (Money contiene
Decimal y String(3)).
Validaciones en propiedades de VOs:
Las propiedades de un VO pueden (y deben) llevar validations cuando el dominio impone
restricciones que el tipo canónico no captura solo. Estas constraints se propagan
automáticamente a toda propiedad de un agregado o entidad que use ese VO como type.
valueObjects:
- name: Money
properties:
- name: amount
type: Decimal
precision: 19
scale: 4
required: true
validations:
- positive: true
- name: currency
type: String(3)
required: true
validations:
- pattern: "^[A-Z]{3}$"
Cuando un agregado declara price: Money, el generador aplica positive y el pattern
en todos los commands que incluyan ese campo — sin necesidad de repetirlos en el agregado.
Leer references/validation.md para el vocabulario completo de constraints y las reglas
de qué declarar y qué no.
3.3b Convención: sufijo Snapshot para VOs que representan estado de entidades en eventos
Cuando un evento de dominio necesita llevar el estado de una entidad en su payload,
ese estado debe viajar como un VO inmutable — no como la entidad misma. La entidad tiene
identidad, ciclo de vida y comportamiento; el VO captura una foto congelada en el momento
exacto del evento.
Nombrar ese VO como {NombreEntidad}Snapshot — por ejemplo, OrderLineSnapshot para
una entidad OrderLine. Este sufijo cumple tres funciones:
- Evita colisión de nombres entre la entidad del dominio y el VO del evento (distintos
namespaces en el generador; nombres iguales generan ambigüedad).
- Determina
source: de forma inequívoca para el payload del evento (ver §3.7).
- Comunica al lector que el campo es una foto inmutable, no una referencia viva.
Estructura del VO snapshot (perspectiva del BC publicador):
valueObjects:
- name: OrderLineSnapshot
immutable: true
properties:
- name: productId
type: Uuid
- name: sku
type: String(50)
- name: quantity
type: Integer
- name: unitPrice
type: Money
Reglas de las propiedades del Snapshot:
- Solo tipos escalares o VOs escalares — no listas, no referencias a otras entidades.
- Incluir solo los campos que el BC consumidor necesita para actuar, no todos los de la entidad.
immutable: true es obligatorio — el generador genera List.copyOf() defensivo en listas.
Perspectiva del BC consumidor: El BC que recibe el evento con OrderLineSnapshot
no debe re-declarar este tipo en su valueObjects[]. Debe declararlo en eventDtos[]
con sourceBc: orders. Esto genera un Java record en application.dtos.incoming/
(no en domain.valueobject/), lo que mantiene el modelo de dominio del consumidor libre
de conceptos ajenos.
Señales para aplicar esta convención: el evento lleva "líneas", "ítems", "productos",
"detalles" o cualquier campo cuyo nombre coincida con una entidad hija del agregado
(lines, items, products, attachments, variants…).
3.3.1 Resolución de tipos — Regla de cierre del vocabulario de tipos
Todo valor en el campo type de cualquier propiedad del YAML debe estar declarado
en este mismo archivo antes de usarse. El generador de código interpreta el YAML como
la fuente de verdad completa del BC: si un tipo aparece referenciado pero no declarado,
el generador falla sin poder inferirlo.
Vocabulario de tipos válidos en {bc-name}.yaml:
| Categoría | Cómo verificar |
|---|
| Tipo canónico | Existe en references/canonical-types.md (Uuid, String, Money, DateTime, etc.) |
| Enum propio | Existe en enums[] de este mismo archivo |
| Value Object propio | Existe en valueObjects[] de este mismo archivo (conceptos del dominio propio) |
| Event DTO externo | Existe en eventDtos[] de este mismo archivo (shapes de eventos de otros BCs) |
Agregado o Entidad (solo para references:) | Existe en aggregates[] o en entities[] del mismo BC |
Esta regla aplica sin excepción a:
aggregates[].properties[].type
aggregates[].entities[].properties[].type
valueObjects[].properties[].type
eventDtos[].properties[].type
domainEvents.published[].payload[].type
domainEvents.consumed[].payload[].type
repositories[].methods[].params[].type
repositories[].methods[].returns (tipo base, sin ?, [] ni Page[...])
Antes de escribir cualquier type: que no sea un primitivo canónico, verifica:
- ¿Está declarado en
enums[]? Si no → declararlo primero.
- ¿Está declarado en
valueObjects[]? Si no → declararlo primero.
- ¿Es un tipo compuesto que viene del payload de un evento de otro BC?
→ Declararlo en
eventDtos[] (NO en valueObjects[]) con sus propiedades.
- ¿Es un tipo compuesto de payload que agrupa campos del mismo BC?
→ Declararlo en
valueObjects[] con sus propiedades antes de usarlo en el payload.
Patrón frecuente de omisión en payloads de eventos: los eventos suelen necesitar
resumir una colección de líneas (ej: OrderLineSummary, CartItemSnapshot). Es fácil
escribir type: OrderLineSummary en el payload sin haber declarado ese tipo. Determinar