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).

Diagram

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 un Task valido — assignee no esta ahi porque una tarea puede existir sin asignar, y comments arranca vacio por definicion.

  • Mutaciones via metodos de dominio (assignTo, addComment). Sin @Setter — el AR controla sus invariantes.

  • Relacion unidireccional @OneToMany con @JoinColumn sobre el lado Task — evita el costo de mantener ambos lados sincronizados y refuerza que Comment no se navega desde fuera del aggregate. Ver referencias DDD sobre diseno de aggregates.

  • LinkedHashSet + @OrderBy — preserva el orden cronologico tanto en memoria (cuando el Task se crea o muta antes del primer save) como al recargar desde DB. HashSet puro 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. Task.createBug(…​) vs Task.createFeature(…​)): solo cuando la forma del objeto cambie segun el tipo — por ejemplo, si un Bug requiere severity y un Feature requiere targetRelease. Multiplicar factories solo porque hay un enum no agrega informacion sobre Task.create(title, type) y ensucia la API. Separar cuando las invariantes divergen, no antes.

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 CustomIdEntity — agrega una tabla sin ganar nada.

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 GET paginado no muta nada, asi que no hay invariante que justificar, y cargar el AR completo para devolver una proyeccion suele ser ineficiente.

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

extends AlphaIdEntity / SequenceEntity / CustomIdEntity<PK>

{domainName}/domain/

Entidad interna

Igual que el aggregate root

{domainName}/domain/

Value object

@Embeddable, incluido con @Embedded

{domainName}/domain/

Catalogo cerrado

enum de Java, persistido con @Enumerated

{domainName}/domain/

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.