Como definir el modelo de dominio
Esta guia explica como modelar los elementos del dominio — aggregate roots, entidades y value objects — en un proyecto generado con el arquetipo.
Que vive en el dominio
| Elemento | Pregunta que lo identifica |
|---|---|
Aggregate root |
Tiene identidad propia y es la raiz de consistencia de un grupo de objetos. Lo que el mundo exterior referencia por ID. |
Entidad |
Tiene identidad propia pero vive dentro de un aggregate, accedida a traves de su root. |
Value object |
No tiene identidad propia. Se define por sus atributos. Generalmente inmutable. |
Aggregate roots y entidades se modelan como clases JPA que heredan de una de las tres clases base. Los value objects se modelan como @Embeddable.
Un ejemplo pequeño: Task
Partamos de un aggregate Task con tres piezas: los comentarios asociados (entidad), el usuario asignado (value object) y el tipo de issue (enumerado).
Task — aggregate root
Task es la raiz del aggregate y lo que se expone por REST. Hereda de AlphaIdEntity para tener un ID alfanumerico no enumerable. Toda mutacion pasa por metodos de dominio — no hay @Setter, la coleccion de comentarios se modifica via addComment(…):
@Entity
@Table(name = "tasks")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Task extends AlphaIdEntity {
private String title;
@Enumerated(EnumType.STRING)
private IssueType type;
@Embedded
private Assignee assignee;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "task_id")
@OrderBy("createdDate ASC")
private Set<Comment> comments = new LinkedHashSet<>();
private Task(String title, IssueType type) {
this.title = title;
this.type = type;
}
public static Task create(String title, IssueType type) {
return new Task(title, type);
}
public void assignTo(Assignee assignee) {
this.assignee = assignee;
}
public void addComment(String body) {
comments.add(new Comment(body));
}
}
Puntos clave del diseno:
-
Factory method
Task.create(title, type)como unico punto de creacion publica. El constructor privado manual lista los campos estrictamente requeridos para unTaskvalido —assigneeno esta ahi porque una tarea puede existir sin asignar, ycommentsarranca vacio por definicion. -
Mutaciones via metodos de dominio (
assignTo,addComment). Sin@Setter— el AR controla sus invariantes. -
Relacion unidireccional
@OneToManycon@JoinColumnsobre el ladoTask— evita el costo de mantener ambos lados sincronizados y refuerza queCommentno se navega desde fuera del aggregate. Ver referencias DDD sobre diseno de aggregates. -
LinkedHashSet+@OrderBy— preserva el orden cronologico tanto en memoria (cuando elTaskse crea o muta antes del primer save) como al recargar desde DB.HashSetpuro daria iteracion no-deterministica in-memory. -
@NoArgsConstructor(access = PROTECTED)— JPA lo necesita para reconstituir entidades desde la base, pero el codigo de dominio no puede usarlo.
|
Cuando introducir factories por tipo (p.ej. |
Comment — entidad dentro del aggregate
Un Comment tiene identidad propia pero no existe sin su Task. Es alto volumen, se lee en orden cronologico y no se expone directamente por API — encaja en SequenceEntity (PK Long de sequence). El constructor es package-private: solo Task.addComment(…) lo invoca.
@Entity
@Table(name = "task_comments")
@SequenceGenerator(name = "entity_seq", sequenceName = "seq_task_comments", allocationSize = 50)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Comment extends SequenceEntity {
private String body;
Comment(String body) { // package-private: solo Task construye
this.body = body;
}
}
Assignee — value object
Un Assignee es un snapshot de usuario: sin identidad propia, se define por sus atributos. Al ser un VO simple, Lombok cubre los dos constructores completos:
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class Assignee {
private String userId;
private String email;
private String name;
}
IssueType — enumerado
El tipo de issue es un conjunto cerrado y pequeño de valores (BUG, FEATURE, CHORE) sin identidad ni ciclo de vida propio. No amerita una tabla ni una clase base — un enum de Java persistido con @Enumerated(EnumType.STRING) es suficiente:
public enum IssueType {
BUG, FEATURE, CHORE
}
|
Catalogos cerrados y estables (tipos, estados, prioridades) casi siempre son enums, no tablas. Resistir la tentacion de modelarlos como entidades con |
Exponer el aggregate por REST
Hay una asimetria importante entre escrituras y lecturas:
|
Las escrituras atraviesan el aggregate root. Las lecturas no estan obligadas. Las invariantes del dominio son invariantes transaccionales — se preservan cuando se muta estado. Un |
Escritura: via el AR
Un endpoint que opera sobre un subrecurso (Comment) carga el Task, invoca un metodo de dominio, y deja que el cascade + orphanRemoval persistan la composicion:
POST /v1/tasks/{taskId}/comments
@Service
@RequiredArgsConstructor
class TaskService implements AddCommentUseCase {
private final TaskRetrievalPort retrievalPort;
private final TaskPersistencePort persistencePort;
@Override
@Transactional
public void addComment(String taskId, String body) {
Task task = retrievalPort.findById(taskId)
.orElseThrow(() -> new ApplicationException(TaskErrors.TASK_NOT_FOUND, taskId));
task.addComment(body);
persistencePort.save(task);
}
}
El servicio depende de output ports (TaskRetrievalPort para lectura, TaskPersistencePort para escritura), no de repositorios JPA directamente. No existe un CommentRepository de escritura — los Comment se crean, modifican y eliminan via el AR.
Lectura: query port dedicado
GET /v1/tasks/{taskId}/comments?size=10
Para listar comentarios paginados, definir un query port (output) separado que devuelve directamente la proyeccion necesaria, sin cargar el Task:
public interface CommentQueryPort {
Page<CommentView> findByTaskId(String taskId, Pageable pageable);
}
El adapter JPA lo implementa con un SELECT directo sobre task_comments. Ventajas: no se paga el costo de cargar el AR ni sus otras colecciones, y la proyeccion (CommentView) se ajusta exactamente a lo que el cliente necesita.
Vernon desarrolla este principio en detalle — ver la tercera parte de _Effective Aggregate Design.
Cuando usar CustomIdEntity<PK>
Ningun concepto del ejemplo anterior la justifica. CustomIdEntity<PK> es para claves naturales definidas por el dominio: codigos ISO de pais, RUC, CUIL, o cualquier identificador que el negocio ya conoce y asigna. El dev asigna el ID antes de save():
@Entity
@Table(name = "countries")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Country extends CustomIdEntity<String> {
private String name;
private Country(String isoCode, String name) {
this.id = isoCode;
this.name = name;
}
public static Country create(String isoCode, String name) {
return new Country(isoCode, name);
}
}
// Uso desde un servicio:
Country peru = Country.create("PE", "Peru");
persistencePort.save(peru);
Elegir la clase base: arbol de decision
¿Quien genera el ID?
├── Hibernate lo genera automaticamente
│ ├── ¿Necesito que sea legible / no enumerable? → AlphaIdEntity (String)
│ └── ¿Necesito orden numerico o FK eficiente? → SequenceEntity (Long)
└── Mi dominio lo conoce de antemano → CustomIdEntity<PK>
Resumen
| Elemento | Como se modela | Donde vive |
|---|---|---|
Aggregate root |
|
|
Entidad interna |
Igual que el aggregate root |
|
Value object |
|
|
Catalogo cerrado |
|
|
Para los contratos completos de cada clase base — auditoria automatica, equals/hashCode, cuando Hibernate asigna el ID, warning de multitenancy con SequenceEntity — ver Clases base JPA.