POC de arquitectura · Java 21 · Spring Boot 3

Dos formas de escribir el mismo TODO

La misma funcionalidad — crear, listar, completar y borrar tareas — implementada dos veces con el mismo stack. La única variable que cambia es cómo se reparte la lógica entre ficheros y directorios: arquitectura hexagonal con DDD frente a un único fichero pensado para que un agente de IA cargue el contexto completo de una sola vez.

Spring Boot 3 · Spring Data JPA Mismos 4 casos de uso en ambas versiones Sin capa de test ni configuración de build

Ambas versiones exponen la misma API sobre /api/todos: crear una tarea, listar todas, marcarla como completada y borrarla. Ninguna añade ni quita funcionalidad respecto a la otra — lo único que varía es la distribución del código fuente. Eso aísla la variable que interesa comparar en esta POC.

Usa los botones de abajo para cambiar de versión, y el árbol de ficheros de la izquierda para navegar por el código de cada una.

Explorador de código

17 ficheros · 399 líneas frente a 1 fichero · 144 líneas
package com.example.todo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class TodoApplication {

    public static void main(String[] args) {
        SpringApplication.run(TodoApplication.class, args);
    }
}
package com.example.todo.application;

import com.example.todo.application.dto.CreateTodoCommand;
import com.example.todo.application.dto.TodoResponse;
import com.example.todo.domain.exception.TodoNotFoundException;
import com.example.todo.domain.model.Todo;
import com.example.todo.domain.model.TodoId;
import com.example.todo.domain.port.in.CompleteTodoUseCase;
import com.example.todo.domain.port.in.CreateTodoUseCase;
import com.example.todo.domain.port.in.DeleteTodoUseCase;
import com.example.todo.domain.port.in.ListTodosUseCase;
import com.example.todo.domain.port.out.TodoRepositoryPort;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class TodoService implements CreateTodoUseCase, ListTodosUseCase, CompleteTodoUseCase, DeleteTodoUseCase {

    private final TodoRepositoryPort repository;

    public TodoService(TodoRepositoryPort repository) {
        this.repository = repository;
    }

    @Override
    public TodoResponse create(CreateTodoCommand command) {
        Todo todo = Todo.create(command.title());
        Todo saved = repository.save(todo);
        return toResponse(saved);
    }

    @Override
    public List<TodoResponse> listAll() {
        return repository.findAll().stream()
                .map(this::toResponse)
                .toList();
    }

    @Override
    public TodoResponse complete(TodoId id) {
        Todo todo = repository.findById(id)
                .orElseThrow(() -> new TodoNotFoundException(id));
        todo.complete();
        Todo saved = repository.save(todo);
        return toResponse(saved);
    }

    @Override
    public void delete(TodoId id) {
        repository.findById(id).orElseThrow(() -> new TodoNotFoundException(id));
        repository.deleteById(id);
    }

    private TodoResponse toResponse(Todo todo) {
        return new TodoResponse(todo.id().toString(), todo.title(), todo.isCompleted(), todo.createdAt());
    }
}
package com.example.todo.application.dto;

public record CreateTodoCommand(String title) {
}
package com.example.todo.application.dto;

import java.time.Instant;

public record TodoResponse(String id, String title, boolean completed, Instant createdAt) {
}
package com.example.todo.domain.exception;

import com.example.todo.domain.model.TodoId;

public class TodoNotFoundException extends RuntimeException {

    public TodoNotFoundException(TodoId id) {
        super("No existe una tarea con id " + id);
    }
}
package com.example.todo.domain.model;

import java.time.Instant;
import java.util.Objects;

public class Todo {

    private final TodoId id;
    private String title;
    private boolean completed;
    private final Instant createdAt;

    private Todo(TodoId id, String title, boolean completed, Instant createdAt) {
        this.id = id;
        this.title = title;
        this.completed = completed;
        this.createdAt = createdAt;
    }

    public static Todo create(String title) {
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("El título de la tarea no puede estar vacío");
        }
        return new Todo(TodoId.newId(), title.trim(), false, Instant.now());
    }

    public static Todo reconstitute(TodoId id, String title, boolean completed, Instant createdAt) {
        return new Todo(id, title, completed, createdAt);
    }

    public void complete() {
        this.completed = true;
    }

    public TodoId id() {
        return id;
    }

    public String title() {
        return title;
    }

    public boolean isCompleted() {
        return completed;
    }

    public Instant createdAt() {
        return createdAt;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Todo other)) return false;
        return Objects.equals(id, other.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}
package com.example.todo.domain.model;

import java.util.UUID;

public record TodoId(UUID value) {

    public static TodoId newId() {
        return new TodoId(UUID.randomUUID());
    }

    public static TodoId of(String raw) {
        return new TodoId(UUID.fromString(raw));
    }

    @Override
    public String toString() {
        return value.toString();
    }
}
package com.example.todo.domain.port.in;

import com.example.todo.application.dto.TodoResponse;
import com.example.todo.domain.model.TodoId;

public interface CompleteTodoUseCase {
    TodoResponse complete(TodoId id);
}
package com.example.todo.domain.port.in;

import com.example.todo.application.dto.CreateTodoCommand;
import com.example.todo.application.dto.TodoResponse;

public interface CreateTodoUseCase {
    TodoResponse create(CreateTodoCommand command);
}
package com.example.todo.domain.port.in;

import com.example.todo.domain.model.TodoId;

public interface DeleteTodoUseCase {
    void delete(TodoId id);
}
package com.example.todo.domain.port.in;

import com.example.todo.application.dto.TodoResponse;

import java.util.List;

public interface ListTodosUseCase {
    List<TodoResponse> listAll();
}
package com.example.todo.domain.port.out;

import com.example.todo.domain.model.Todo;
import com.example.todo.domain.model.TodoId;

import java.util.List;
import java.util.Optional;

public interface TodoRepositoryPort {
    Todo save(Todo todo);
    Optional<Todo> findById(TodoId id);
    List<Todo> findAll();
    void deleteById(TodoId id);
}
package com.example.todo.infrastructure.in.web;

import com.example.todo.domain.exception.TodoNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(TodoNotFoundException.class)
    public ResponseEntity<Map<String, String>> handleNotFound(TodoNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(Map.of("error", ex.getMessage()));
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException ex) {
        return ResponseEntity.badRequest()
                .body(Map.of("error", ex.getMessage()));
    }
}
package com.example.todo.infrastructure.in.web;

import com.example.todo.application.dto.CreateTodoCommand;
import com.example.todo.application.dto.TodoResponse;
import com.example.todo.domain.model.TodoId;
import com.example.todo.domain.port.in.CompleteTodoUseCase;
import com.example.todo.domain.port.in.CreateTodoUseCase;
import com.example.todo.domain.port.in.DeleteTodoUseCase;
import com.example.todo.domain.port.in.ListTodosUseCase;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/todos")
public class TodoController {

    private final CreateTodoUseCase createTodoUseCase;
    private final ListTodosUseCase listTodosUseCase;
    private final CompleteTodoUseCase completeTodoUseCase;
    private final DeleteTodoUseCase deleteTodoUseCase;

    public TodoController(CreateTodoUseCase createTodoUseCase,
                           ListTodosUseCase listTodosUseCase,
                           CompleteTodoUseCase completeTodoUseCase,
                           DeleteTodoUseCase deleteTodoUseCase) {
        this.createTodoUseCase = createTodoUseCase;
        this.listTodosUseCase = listTodosUseCase;
        this.completeTodoUseCase = completeTodoUseCase;
        this.deleteTodoUseCase = deleteTodoUseCase;
    }

    @PostMapping
    public ResponseEntity<TodoResponse> create(@RequestBody CreateTodoCommand command) {
        TodoResponse response = createTodoUseCase.create(command);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @GetMapping
    public ResponseEntity<List<TodoResponse>> listAll() {
        return ResponseEntity.ok(listTodosUseCase.listAll());
    }

    @PatchMapping("/{id}/complete")
    public ResponseEntity<TodoResponse> complete(@PathVariable String id) {
        return ResponseEntity.ok(completeTodoUseCase.complete(TodoId.of(id)));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable String id) {
        deleteTodoUseCase.delete(TodoId.of(id));
        return ResponseEntity.noContent().build();
    }
}
package com.example.todo.infrastructure.out.persistence;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.UUID;

public interface SpringDataTodoRepository extends JpaRepository<TodoJpaEntity, UUID> {
}
package com.example.todo.infrastructure.out.persistence;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "todos")
public class TodoJpaEntity {

    @Id
    private UUID id;
    private String title;
    private boolean completed;
    private Instant createdAt;

    protected TodoJpaEntity() {
        // requerido por JPA
    }

    public TodoJpaEntity(UUID id, String title, boolean completed, Instant createdAt) {
        this.id = id;
        this.title = title;
        this.completed = completed;
        this.createdAt = createdAt;
    }

    public UUID getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public boolean isCompleted() {
        return completed;
    }

    public Instant getCreatedAt() {
        return createdAt;
    }
}
package com.example.todo.infrastructure.out.persistence;

import com.example.todo.domain.model.Todo;
import com.example.todo.domain.model.TodoId;
import com.example.todo.domain.port.out.TodoRepositoryPort;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Optional;

@Component
public class TodoPersistenceAdapter implements TodoRepositoryPort {

    private final SpringDataTodoRepository jpaRepository;

    public TodoPersistenceAdapter(SpringDataTodoRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }

    @Override
    public Todo save(Todo todo) {
        TodoJpaEntity entity = new TodoJpaEntity(
                todo.id().value(), todo.title(), todo.isCompleted(), todo.createdAt());
        TodoJpaEntity saved = jpaRepository.save(entity);
        return toDomain(saved);
    }

    @Override
    public Optional<Todo> findById(TodoId id) {
        return jpaRepository.findById(id.value()).map(this::toDomain);
    }

    @Override
    public List<Todo> findAll() {
        return jpaRepository.findAll().stream().map(this::toDomain).toList();
    }

    @Override
    public void deleteById(TodoId id) {
        jpaRepository.deleteById(id.value());
    }

    private Todo toDomain(TodoJpaEntity entity) {
        return Todo.reconstitute(
                new TodoId(entity.getId()), entity.getTitle(), entity.isCompleted(), entity.getCreatedAt());
    }
}
package com.example.todoagent;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;

// Todo el dominio, la persistencia y la API viven en este único fichero.
// Pensado para que un agente IA pueda cargar el contexto completo de la
// funcionalidad "todos" de una sola vez, sin recorrer el árbol de directorios.

@SpringBootApplication
public class TodoAgentApplication {

    public static void main(String[] args) {
        SpringApplication.run(TodoAgentApplication.class, args);
    }
}

@Entity
@Table(name = "todos")
class Todo {

    @Id
    private UUID id = UUID.randomUUID();
    private String title;
    private boolean completed = false;
    private Instant createdAt = Instant.now();

    protected Todo() {
        // requerido por JPA
    }

    Todo(String title) {
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("El título de la tarea no puede estar vacío");
        }
        this.title = title.trim();
    }

    void complete() {
        this.completed = true;
    }

    UUID getId() {
        return id;
    }

    String getTitle() {
        return title;
    }

    boolean isCompleted() {
        return completed;
    }

    Instant getCreatedAt() {
        return createdAt;
    }
}

interface TodoRepository extends JpaRepository<Todo, UUID> {
}

record CreateTodoRequest(String title) {
}

record TodoResponse(String id, String title, boolean completed, Instant createdAt) {
    static TodoResponse from(Todo todo) {
        return new TodoResponse(todo.getId().toString(), todo.getTitle(), todo.isCompleted(), todo.getCreatedAt());
    }
}

class TodoNotFoundException extends RuntimeException {
    TodoNotFoundException(UUID id) {
        super("No existe una tarea con id " + id);
    }
}

@RestController
@RequestMapping("/api/todos")
class TodoController {

    private final TodoRepository repository;

    TodoController(TodoRepository repository) {
        this.repository = repository;
    }

    @PostMapping
    ResponseEntity<TodoResponse> create(@RequestBody CreateTodoRequest request) {
        Todo saved = repository.save(new Todo(request.title()));
        return ResponseEntity.status(HttpStatus.CREATED).body(TodoResponse.from(saved));
    }

    @GetMapping
    List<TodoResponse> listAll() {
        return repository.findAll().stream().map(TodoResponse::from).toList();
    }

    @PatchMapping("/{id}/complete")
    ResponseEntity<TodoResponse> complete(@PathVariable UUID id) {
        Todo todo = repository.findById(id).orElseThrow(() -> new TodoNotFoundException(id));
        todo.complete();
        return ResponseEntity.ok(TodoResponse.from(repository.save(todo)));
    }

    @DeleteMapping("/{id}")
    ResponseEntity<Void> delete(@PathVariable UUID id) {
        if (!repository.existsById(id)) {
            throw new TodoNotFoundException(id);
        }
        repository.deleteById(id);
        return ResponseEntity.noContent().build();
    }

    @ExceptionHandler(TodoNotFoundException.class)
    ResponseEntity<Map<String, String>> handleNotFound(TodoNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", ex.getMessage()));
    }

    @ExceptionHandler(IllegalArgumentException.class)
    ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException ex) {
        return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage()));
    }
}

Comparación objetiva

Hechos estructurales medidos sobre este mismo código, y las características que se derivan directamente de esa estructura. Sin juicio de valor: cada fila describe cómo se comporta cada enfoque, no cuál es preferible.

Dimensión Hexagonal + DDD Single-file
Ficheros .java 17 1
Líneas totales 399 144
Líneas por fichero Media 23,5 · rango 4–62 144 en un bloque
Profundidad de paquetes 5 niveles (p. ej. domain/port/in) 1 nivel
Tipos por fichero Un tipo público por fichero (clase, interfaz o record). Siete tipos (app, entidad, repositorio, 2 DTOs, excepción y controlador) en el mismo fichero.
Rastro de una operación Completar una tarea recorre 5 ficheros: CompleteTodoUseCase, TodoService, TodoRepositoryPort, TodoPersistenceAdapter y TodoController. Completar una tarea es un único método complete() de ~10 líneas contiguas.
Acoplamiento entre capas El dominio no importa Spring ni JPA; las capas solo se comunican a través de interfaces (puertos). Controlador, entidad JPA y lógica de negocio se referencian directamente entre sí en el mismo fichero.
Añadir una operación nueva Implica un nuevo puerto de entrada, su implementación en el servicio y su exposición en el controlador. Implica un nuevo método en la misma clase de controlador.
Localidad de lectura Entender un flujo completo requiere saltar entre paquetes y ficheros. El flujo completo es visible con un único scroll.
Test unitario de negocio El caso de uso se testea contra el puerto de salida, sin levantar contexto de Spring. Testear la lógica implica instanciar o mockear la misma clase que expone el HTTP.
Ficheros a abrir por un agente IA Hasta 5 ficheros de rutas distintas para reconstruir el contexto de una sola operación. 1 fichero contiene el contexto completo de la funcionalidad.

Coste de tokens para un agente de IA

Estimación calculada sobre el código real de esta POC — no sobre cifras de ejemplo — para dos tareas típicas de un agente: cargar todo el contexto de la funcionalidad, y entender solo el flujo de completar una tarea. Cada fichero se cuenta tal y como lo devuelve una herramienta de lectura habitual (formato cat -n, con número de línea).

Escenario Hexagonal + DDD Single-file
Lecturas de fichero — contexto completo 17 llamadas 1 llamada
Tokens de entrada — contexto completo ≈ 4.850 tokens ≈ 1.415 tokens
Lecturas de fichero — solo “completar tarea” 5 llamadas 1 llamada
Tokens de entrada — solo “completar tarea” ≈ 2.175 tokens ≈ 1.415 tokens
Coste aprox.* — contexto completo ≈ $0,0097 ≈ $0,0028
Coste aprox.* — solo “completar tarea” ≈ $0,0044 ≈ $0,0028

* A precio de lista de Claude Sonnet 5 en la API (2 $/millón de tokens de entrada, agosto de 2026), solo tokens de entrada. Los tokens son una aproximación: dos métodos independientes (caracteres÷4 y un tokenizador de palabras/puntuación) coinciden dentro de un 3 %, pero no es el tokenizador exacto de Claude, que no es público. A cada llamada de lectura se le suman ~75 tokens fijos de coste de la llamada (nombre de herramienta, ruta, envoltorio de la respuesta) — una estimación conservadora que no incluye el razonamiento que un agente suele escribir entre una llamada y la siguiente.

El efecto se acumula por turno, no por lectura. En una sesión de varios turnos, la mayoría de agentes reenvían el historial completo de la conversación en cada llamada a la API: los ficheros ya leídos se retransmiten — y se facturan — en cada turno posterior, hasta que la conversación se resume o se compacta. Con una funcionalidad de este tamaño la diferencia son céntimos; en un servicio con decenas de features y cientos de ficheros, el mismo patrón estructural — dispersión frente a localidad — es lo que dispara el consumo de tokens de un agente a lo largo de una sesión larga.

Nota de alcance. Esta POC aísla un único eje — cómo se distribuye el código entre ficheros y directorios — manteniendo fijos el lenguaje, el framework y la funcionalidad. No incluye tests, seguridad, validación exhaustiva ni configuración de despliegue en ninguna de las dos versiones, porque no son relevantes para la comparación estructural.