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.
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.
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()));
}
}
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. |
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.