En los dos artículos anteriores establecimos las piezas por separado: qué tipo de test corresponde a cada capa de una arquitectura hexagonal, y en qué orden escribirlos para que un agente de IA no termine llenando el proyecto de mocks. Este artículo cierra la serie juntando ambas cosas en un ejercicio concreto: construir un módulo de backend completo, capa por capa, con el agente trabajando dentro del ciclo rojo–verde–refactor como copiloto, no como reemplazo del criterio del desarrollador.
El objetivo no es mostrar más código de ejemplo por el gusto de mostrarlo, sino dejar un flujo de trabajo replicable: qué le pides al agente en cada paso, qué revisas antes de aceptar lo que propone, y dónde sigue siendo indispensable la decisión de un desarrollador senior.
El backend que vamos a construir
Seguimos con el dominio de órdenes de los artículos anteriores, pero le agregamos dos piezas para que el ejercicio cubra más terreno: la posibilidad de cancelar una orden, que introduce una segunda regla de negocio y un segundo puerto de salida (una notificación al cliente), y un endpoint de consulta para leer el estado de una orden. Con esto tenemos los cuatro tipos de trabajo que aparecen en cualquier backend real: comandos con reglas de negocio, comandos que integran con un servicio externo, consultas, y el ensamblado final vía HTTP.
El flujo de trabajo con el agente como copiloto
Antes del código, vale la pena describir la mecánica del ciclo tal como se usa en la práctica. No es "pedirle al agente que implemente la feature" de una vez. Es una secuencia corta que se repite muchas veces, con puntos de control explícitos:
- El desarrollador describe el siguiente comportamiento, en una frase, no en una feature completa. Por ejemplo: "una orden confirmada no puede cancelarse si ya fue enviada".
- El agente propone un test que falla, ubicado en la capa correcta según las reglas de los artículos anteriores (carpeta, dependencias, qué se rompe si falla).
- El desarrollador confirma que el test falla por el motivo correcto. Un test en rojo por un error de compilación no es lo mismo que un test en rojo por una aserción que falla; solo el segundo caso confirma que el test está bien escrito.
- El agente implementa el mínimo código necesario para pasar a verde. Nada de anticipar funcionalidad que el test todavía no pide.
- El desarrollador revisa el refactor, o lo pide explícitamente si el agente lo omitió. Este paso es el que más se salta bajo presión, y es el que evita que la deuda técnica se acumule en silencio.
- Se hace commit del ciclo completo antes de pasar al siguiente comportamiento.
El punto 3 merece énfasis: los agentes, igual que los desarrolladores apurados, a veces "confirman" un rojo sin mirar el motivo real. Un test que falla porque la clase no existe todavía es un rojo válido en el primer ciclo de una clase nueva, pero si aparece en el ciclo número quince de la misma clase, probablemente indica un error de tipeo, no una aserción real. Vale la pena pedir explícitamente el mensaje de error antes de aceptar que el rojo es correcto.
Ciclo completo aplicado: cancelar una orden
Paso 1 — Dominio: la regla de cancelación
Empezamos, como en el artículo anterior, sin ningún puerto de por medio.
[Fact]
public void Cancel_deberia_fallar_si_la_orden_ya_fue_enviada()
{
var order = Order.Create(CustomerId.New(), DateTime.UtcNow);
order.AddLine(ProductId.New(), 1, new Money(10, "USD"));
order.Confirm();
order.MarkAsShipped();
var action = () => order.Cancel();
action.Should().Throw<InvalidOperationException>()
.WithMessage("No se puede cancelar una orden ya enviada.");
}
[Fact]
public void Cancel_deberia_cambiar_el_estado_a_cancelada_si_la_orden_no_fue_enviada()
{
var order = Order.Create(CustomerId.New(), DateTime.UtcNow);
order.AddLine(ProductId.New(), 1, new Money(10, "USD"));
order.Confirm();
order.Cancel();
order.Status.Should().Be(OrderStatus.Cancelled);
}
El agente implementa Cancel() y MarkAsShipped() sobre Order. Ningún mock, ninguna interfaz nueva: es exactamente el mismo tipo de ciclo que ya vimos, solo que con una regla distinta. Este paso no debería sorprenderte; esa previsibilidad es justamente la señal de que el flujo está funcionando.
Paso 2 — Aplicación: un caso de uso con dos puertos
Cancelar una orden no solo cambia su estado: también dispara una notificación al cliente. Esto introduce un segundo puerto, y es un buen punto para poner a prueba la regla de "como máximo dos dobles por test" del artículo anterior.
public class CancelOrderUseCase
{
private readonly IOrderRepository _repository;
private readonly IOrderNotifier _notifier;
public CancelOrderUseCase(IOrderRepository repository, IOrderNotifier notifier)
{
_repository = repository;
_notifier = notifier;
}
public async Task Handle(CancelOrderCommand command)
{
var order = await _repository.GetById(command.OrderId)
?? throw new OrderNotFoundException(command.OrderId);
order.Cancel();
await _repository.Save(order);
await _notifier.NotifyCancellation(order);
}
}
[Fact]
public async Task Handle_deberia_notificar_al_cliente_cuando_la_orden_se_cancela()
{
var order = Order.Create(CustomerId.New(), DateTime.UtcNow);
order.AddLine(ProductId.New(), 1, new Money(10, "USD"));
order.Confirm();
var repository = Substitute.For<IOrderRepository>();
repository.GetById(order.Id).Returns(order);
var notifier = Substitute.For<IOrderNotifier>();
var useCase = new CancelOrderUseCase(repository, notifier);
await useCase.Handle(new CancelOrderCommand(order.Id));
await notifier.Received(1).NotifyCancellation(Arg.Is<Order>(o => o.Status == OrderStatus.Cancelled));
}
Dos mocks, dos puertos, ambos justificables por nombre: IOrderRepository para persistencia, IOrderNotifier para la notificación. Si en este punto el agente propusiera un tercer mock —digamos, para un servicio de auditoría— sería el momento de preguntarse si CancelOrderUseCase está asumiendo demasiadas responsabilidades, tal como señala la regla 4 del artículo anterior.
Paso 3 — Adaptadores: implementaciones reales de ambos puertos
Cada puerto se valida por separado, contra su tecnología real. El repositorio, contra Postgres con Testcontainers; el notificador, contra un servicio de email simulado con WireMock.NET.
[Fact]
public async Task NotifyCancellation_deberia_llamar_al_servicio_de_email_con_los_datos_correctos()
{
_wireMockServer
.Given(Request.Create().WithPath("/emails").UsingPost())
.RespondWith(Response.Create().WithStatusCode(202));
var notifier = new EmailOrderNotifier(_httpClient);
var order = /* orden cancelada de ejemplo */;
await notifier.NotifyCancellation(order);
_wireMockServer.LogEntries.Should().ContainSingle(e => e.RequestMessage.Path == "/emails");
}
Este test no usa ningún mock de la interfaz IOrderNotifier: la implementación real es el sistema bajo prueba, y lo que se sustituye es el servicio externo de email, mediante un servidor HTTP simulado. Es la contraparte exacta del test de aplicación del paso anterior: allá probamos que el caso de uso llama al puerto; acá probamos que el adaptador cumple lo que el puerto promete.
Paso 4 — Entrada: el endpoint y la consulta
Con el caso de uso y sus adaptadores probados, el controller es la pieza más simple de todo el ciclo: solo traduce HTTP a comandos.
[Fact]
public async Task POST_orders_id_cancel_deberia_devolver_204_si_la_orden_existe()
{
var orderId = await CrearOrdenConfirmadaDePrueba();
var response = await _client.PostAsync($"/api/orders/{orderId}/cancel", null);
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
[Fact]
public async Task GET_orders_id_deberia_devolver_el_estado_actual_de_la_orden()
{
var orderId = await CrearOrdenConfirmadaDePrueba();
var response = await _client.GetFromJsonAsync<OrderResponse>($"/api/orders/{orderId}");
response!.Status.Should().Be("Confirmed");
}
El endpoint de consulta merece una aclaración: si tu lectura es una simple proyección sin reglas de negocio, no necesita pasar por el dominio ni por un caso de uso completo; puede resolverse con una query directa contra el modelo de lectura, probada como integración de adaptador. Forzar toda lectura a atravesar entidades de dominio es, de nuevo, sobre-ingeniería innecesaria: la arquitectura hexagonal no exige simetría entre comandos y consultas.
Cómo organizar el repositorio para que el agente no se pierda
El flujo anterior funciona de forma consistente solo si la estructura del proyecto refuerza las señales que el agente usa para decidir capa y tipo de test. En un backend .NET esto se traduce en proyectos separados que espejan tanto el código como los tests:
| Proyecto de código | Proyecto de tests | Contenido |
|---|---|---|
| Orders.Domain | Orders.Domain.Tests | Entidades, value objects, reglas de negocio |
| Orders.Application | Orders.Application.Tests | Casos de uso, puertos (interfaces) |
| Orders.Infrastructure | Orders.Infrastructure.Tests | Repositorios EF Core, notificadores, adaptadores |
| Orders.Api | Orders.Api.Tests | Controllers, endpoints, composición |
Con esta estructura, la referencia de proyectos hace cumplir la regla de dependencias por sí sola: Orders.Domain no puede referenciar Entity Framework aunque alguien lo intente, porque ese paquete ni siquiera está instalado ahí. Es una restricción más fuerte que cualquier instrucción en un prompt, y es la primera línea de defensa contra que un agente filtre infraestructura hacia el dominio.
En el pipeline de CI conviene separar las etapas: los tests de Domain y Application corren en segundos en cada push, mientras que los de Infrastructure (con Testcontainers) y los e2e corren en una etapa aparte, más lenta, que no bloquea el feedback inmediato del desarrollador ni del agente durante el ciclo rojo–verde.
Rol del agente frente al rol del desarrollador senior
Vale la pena ser explícito sobre dónde termina la autonomía razonable del agente. El agente puede proponer el test, implementar el código mínimo, y sugerir el refactor: todo el mecanismo de los artículos anteriores está pensado para que haga eso de forma confiable. Lo que sigue requiriendo criterio humano es distinto: decidir si una regla de negocio pertenece a esta entidad o a otra, decidir cuándo un caso de uso debería dividirse en dos, decidir si vale la pena introducir un nuevo agregado, o decidir qué flujos merecen un test e2e y cuáles no. Son decisiones de diseño con consecuencias a largo plazo, y el agente no tiene el contexto de negocio completo para tomarlas solo.
Una señal práctica de cuándo intervenir: si el agente empieza a ampliar el alcance del test más allá de lo que pediste en el paso 1 del ciclo —agregando validaciones extra, casos límite no solicitados, o parámetros nuevos "por si acaso"— es momento de frenar y volver a un paso a la vez. TDD funciona por incrementos pequeños; un agente que se adelanta rompe esa disciplina tan fácilmente como un desarrollador apurado.
Errores que aparecen al escalar esto a un backend completo
Con un solo caso de uso, todo esto se ve ordenado. Los problemas aparecen cuando el proyecto crece a decenas de casos de uso y varios agentes o desarrolladores trabajan en paralelo. El más común es la deriva de convenciones: sin un documento vivo que fije las reglas de las secciones anteriores, cada sesión del agente tiende a reinterpretarlas, y con el tiempo aparecen mocks de dominio, tests de integración mal ubicados, o casos de uso con más de dos puertos. Mantener esas reglas escritas en un archivo del repositorio que el agente lea al iniciar cada sesión es la mitigación más efectiva.
El segundo problema es de rendimiento de la suite: a medida que se acumulan tests de integración con Testcontainers, el ciclo de feedback se alarga. Compartir un único contenedor entre todos los tests de una colección (en vez de levantar uno por test) y reservar Testcontainers exclusivamente para adaptadores, sin filtrar hacia tests que deberían ser unitarios, mantiene el costo bajo control.
El tercer problema es el más silencioso: el refactor que se salta sistemáticamente. Un agente en modo productivo tiende a encadenar rojo–verde–rojo–verde sin pausas, porque cada paso individual "funciona". La deuda técnica que se acumula así no aparece en ningún test fallido; aparece semanas después, en un caso de uso que se volvió difícil de tocar. Pedir explícitamente una pasada de refactor cada cierto número de ciclos —no dejarla como paso opcional implícito— es la forma de evitarlo.
Cierre de la serie
Las tres piezas de esta serie se sostienen entre sí: sin saber qué testear en cada capa, el ciclo de TDD no tiene dónde apoyarse; sin un orden inside-out, un agente llena de mocks lo que debería ser dominio real; y sin un flujo de trabajo explícito de rojo–verde–refactor con puntos de control humanos, ni la mejor arquitectura evita que la disciplina se erosione con el tiempo. Ninguna de las tres reemplaza el criterio de un desarrollador senior: lo que hacen es darle al agente un marco lo bastante concreto como para ser útil dentro de ese criterio, en lugar de necesitar supervisión línea por línea.