https://youtu.be/De2qlqKQbwk
https://youtu.be/b8g3bNl0gGI
https://youtu.be/graKa9A7zSs
https://youtu.be/Du46IvxRTtU
https://youtu.be/2qwf10C4CjI
https://youtu.be/jh5mZ1_vhmg
Compartir con la comunidad todo aquello que pueda resultar útil para las personas interesadas en software development, software craftsmanship, agile, leadership, etc
https://youtu.be/De2qlqKQbwk
https://youtu.be/b8g3bNl0gGI
https://youtu.be/graKa9A7zSs
https://youtu.be/Du46IvxRTtU
https://youtu.be/2qwf10C4CjI
https://youtu.be/jh5mZ1_vhmg
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
En el artículo anterior definimos qué tipo de test corresponde a cada capa de una arquitectura hexagonal: unitarios sin dobles para el dominio, unitarios con dobles de puertos para los casos de uso, e integración real para los adaptadores. Esa clasificación resuelve el "qué" y el "dónde". Falta el "en qué orden" se escribe todo eso cuando el código lo produce, total o parcialmente, un agente de IA. Ahí es donde entra el TDD inside-out.
La elección del orden no es un detalle metodológico menor. Es la diferencia entre terminar con un dominio real, probado y expresivo, o terminar con una suite verde llena de mocks que no verifica nada de negocio.
El TDD siempre tuvo dos escuelas. La escuela outside-in (o "mockista", asociada a Londres) empieza por el borde del sistema: se escribe un test de aceptación o de un caso de uso, y a medida que se descubren colaboradores necesarios, se los mockea para poder avanzar sin implementarlos todavía. El diseño emerge de afuera hacia adentro. La escuela inside-out (o "clásica", asociada a Detroit) empieza por el dominio: se construyen y prueban primero los objetos de negocio con sus reglas, y recién después se ensamblan en casos de uso y adaptadores.
Con un desarrollador humano experimentado, ambas escuelas funcionan razonablemente bien porque la persona tiene criterio para saber cuándo un mock es una decisión de diseño legítima y cuándo es un atajo para evitar pensar el dominio. Un agente de IA no tiene ese criterio por defecto: su objetivo inmediato es hacer pasar el test lo más rápido posible. Si le permites empezar por afuera —por el controller o el caso de uso— y le das libertad para mockear lo que haga falta, va a mockear todo lo que le resulte conveniente, incluida lógica de negocio que todavía no existe. El resultado es una suite en verde que no prueba nada real: el "sistema bajo prueba" termina siendo, en gran parte, los propios mocks.
Inside-out resuelve esto por construcción. Si obligas al agente a empezar por el dominio, no hay nada que mockear todavía: las entidades y value objects no tienen dependencias externas. El agente se ve forzado a diseñar y probar comportamiento real antes de que exista ningún puerto que sustituir. Los mocks solo entran en escena cuando genuinamente hay una frontera —un puerto— que cruzar, y ese momento llega varios pasos después, no en el primer test.
Estas reglas funcionan mejor cuando se dejan explícitas en las instrucciones del agente (por ejemplo, en un archivo de convenciones del repositorio), no como un criterio implícito que se espera que el agente adivine.
Regla 1: no hay mocks sin un puerto. Un test solo puede usar un doble de prueba si sustituye una interfaz definida como puerto en la capa de aplicación. Si el agente propone mockear una clase del dominio, una entidad, o un value object, la respuesta es no: eso indica que falta terminar de diseñar el dominio, no que haga falta un mock.
Regla 2: el dominio se prueba y queda en verde antes de escribir el primer test de aplicación. No se permite empezar un caso de uso mientras las entidades que necesita todavía no tienen su comportamiento cubierto. Esto evita que el agente "salte" al nivel de orquestación y compense con mocks lo que debería ser lógica de dominio real.
Regla 3: un mock representa un puerto, no un detalle de implementación. Se mockea IOrderRepository, nunca DbContext. Se mockea IPaymentGateway, nunca HttpClient. Si el agente necesita mockear algo que no es una interfaz propia de la capa de aplicación, probablemente está intentando testear un adaptador con las herramientas equivocadas.
Regla 4: un test, un puerto sustituido (como máximo dos). Si un caso de uso necesita cuatro o cinco mocks para que el test pase, es una señal de que el caso de uso está haciendo demasiado. La respuesta correcta no es aceptar el test con cinco dobles, sino dividir el caso de uso o mover lógica al dominio.
Regla 5: todo mock debe poder justificarse señalando la interfaz que sustituye. Es una regla simple de aplicar como revisión: si no puedes nombrar el puerto que ese mock representa, el mock no debería existir.
En la práctica, estas reglas se pueden resumir en una instrucción corta para el agente: "Antes de escribir un test con un doble de prueba, verifica que estás en la capa de aplicación y que el doble sustituye una interfaz de puerto ya definida. Si no se cumple, escribe primero el test de dominio correspondiente."
Vale más ver el ciclo completo con un ejemplo que enumerarlo en abstracto. Retomamos el caso de "crear una orden" del artículo anterior y lo construimos paso a paso, capa por capa, con rojo–verde–refactor en cada una.
Empezamos sin ningún puerto, sin ninguna interfaz, sin nada que mockear. El primer test describe una regla del dominio antes de que exista el código que la cumple.
// Rojo: Order todavía no existe
[Fact]
public void AddLine_deberia_agregar_una_linea_a_una_orden_en_borrador()
{
var order = Order.Create(CustomerId.New(), DateTime.UtcNow);
order.AddLine(ProductId.New(), quantity: 2, unitPrice: new Money(10, "USD"));
order.Lines.Should().ContainSingle();
}
El agente implementa lo mínimo para pasar a verde: la clase Order, el método Create, el método AddLine. Nada de repositorios, nada de infraestructura. En el refactor se limpia el diseño si hace falta, pero el test sigue siendo el mismo: cero dobles de prueba.
// Rojo → Verde: se agrega la siguiente regla de negocio
[Fact]
public void Confirm_deberia_fallar_si_la_orden_no_tiene_lineas()
{
var order = Order.Create(CustomerId.New(), DateTime.UtcNow);
var action = () => order.Confirm();
action.Should().Throw<InvalidOperationException>();
}
Este ciclo se repite tantas veces como reglas de negocio tenga la orden: líneas duplicadas, límites de cantidad, cálculo del total. Recién cuando el dominio cubre el comportamiento que el caso de uso va a necesitar, se sube a la siguiente capa.
Con Order ya probado, escribimos el primer test que sí necesita un doble de prueba, porque el caso de uso depende de un puerto real: persistir la orden.
// Rojo: PlaceOrderUseCase todavía no existe
[Fact]
public async Task Handle_deberia_guardar_la_orden_confirmada()
{
var repository = Substitute.For<IOrderRepository>();
var clock = Substitute.For<IClock>();
clock.UtcNow.Returns(new DateTime(2026, 7, 1));
var useCase = new PlaceOrderUseCase(repository, clock);
var command = new PlaceOrderCommand(
CustomerId.New(),
new[] { new OrderItemDto(ProductId.New(), 2, new Money(10, "USD")) });
await useCase.Handle(command);
await repository.Received(1).Save(Arg.Is<Order>(o => o.Status == OrderStatus.Confirmed));
}
Los dos dobles, IOrderRepository e IClock, son puertos definidos por la aplicación: cumplen la regla 3. No hay tercer mock porque el caso de uso no necesita nada más: cumple la regla 4. El agente implementa PlaceOrderUseCase usando el Order ya construido en el paso anterior; no vuelve a decidir reglas de negocio aquí, solo orquesta.
El caso de uso quedó en verde confiando en que IOrderRepository.Save hace lo que promete. Ese contrato todavía no se probó de verdad. El siguiente ciclo rojo–verde ocurre contra infraestructura real, no contra un doble.
// Rojo: OrderRepositoryEfCore todavía no existe
[Fact]
public async Task Save_deberia_persistir_la_orden_con_sus_lineas()
{
var repository = new OrderRepositoryEfCore(_context);
var order = Order.Create(CustomerId.New(), DateTime.UtcNow);
order.AddLine(ProductId.New(), 2, new Money(10, "USD"));
await repository.Save(order);
var stored = await _context.Orders.Include(o => o.Lines).FirstAsync(o => o.Id == order.Id);
stored.Lines.Should().HaveCount(1);
}
Aquí no hay ningún mock: es integración real contra Postgres levantado con Testcontainers, como vimos en el artículo anterior. El agente implementa el mapeo de EF Core, resuelve las particularidades del ORM, y recién en este punto el puerto IOrderRepository queda validado de extremo a extremo, no solo asumido.
El último tramo conecta todo con el mundo exterior: un test de integración sobre el controller, usando WebApplicationFactory, que ejercita el pipeline completo de HTTP hacia el caso de uso ya probado.
[Fact]
public async Task POST_orders_deberia_devolver_201_con_una_orden_valida()
{
var response = await _client.PostAsJsonAsync("/api/orders", new
{
customerId = Guid.NewGuid(),
items = new[] { new { productId = Guid.NewGuid(), quantity = 1, unitPrice = 25.0 } }
});
response.StatusCode.Should().Be(HttpStatusCode.Created);
}
Notá el recorrido completo: dominio probado sin dobles, aplicación probada con dobles de puertos, adaptadores probados contra tecnología real, y solo al final un test de extremo a extremo del endpoint. Cada capa se apoyó en la anterior ya verificada, en lugar de asumirla con un mock adicional. Ese es precisamente el efecto que buscamos: que el agente construya el sistema de adentro hacia afuera, con evidencia real en cada paso, en vez de simular todo desde el borde.
Inside-out TDD no es una preferencia estilística: es la disciplina que evita que un agente de IA resuelva "hacer pasar el test" con el camino de menor resistencia, que casi siempre es mockear. Empezar por el dominio, subir a los casos de uso solo cuando el dominio está probado, y reservar los mocks exclusivamente para puertos, produce un backend donde cada capa fue verificada con la evidencia que le corresponde. En el próximo artículo de la serie llevamos este ciclo a un backend completo, con el agente actuando como copiloto dentro del propio ciclo rojo–verde–refactor.
La arquitectura hexagonal separa el dominio de la infraestructura mediante puertos y adaptadores. Esa separación es exactamente lo que necesitamos para dejar de improvisar con los tests: cada capa tiene una responsabilidad distinta, así que cada capa necesita un tipo de test distinto. El problema aparece cuando esa correspondencia no está explícita. Un desarrollador —o un agente de IA generando código— termina rellenando todo con mocks, escribiendo integration tests para lógica pura, o cubriendo con e2e cosas que un unit test resolvería en milisegundos.
Este artículo es el primero de una serie sobre testing y TDD asistido por agentes. Antes de hablar de TDD inside-out o de construir un backend completo con IA como copiloto, hace falta fijar las reglas del juego: qué se testea en cada capa, con qué tipo de test, y con qué herramienta del ecosistema .NET.
Para que la estrategia de testing tenga sentido, conviene tener clara la estructura. En hexagonal trabajamos con tres capas concéntricas:
Dominio. Entidades, value objects, agregados y domain services. Contiene las reglas de negocio puras. No conoce Entity Framework, no conoce HTTP, no conoce nada externo. Es C# puro.
Aplicación. Los casos de uso (application services) que orquestan el dominio para cumplir una intención del usuario. Esta capa define puertos: interfaces como IOrderRepository o IPaymentGateway que describen qué necesita el caso de uso, sin saber cómo se implementa.
Adaptadores. La implementación concreta de los puertos: repositorios con EF Core, clientes HTTP hacia APIs externas, controllers que exponen la aplicación por REST, consumidores de colas, etc. Aquí vive todo lo que toca el mundo exterior.
La regla de dependencia es siempre hacia adentro: los adaptadores dependen de la aplicación, la aplicación depende del dominio, y el dominio no depende de nada. Esa misma dirección es la que vamos a usar para decidir qué tipo de test corresponde a cada capa.
El dominio es el lugar donde los unit tests rinden al máximo: son rápidos, deterministas, y no necesitan ningún doble de prueba porque no hay dependencias externas que sustituir. Si sientes la necesidad de mockear algo dentro de un test de dominio, es una señal de que esa clase tiene una responsabilidad que no le corresponde (probablemente está tocando infraestructura).
public class Order
{
private readonly List<OrderLine> _lines = new();
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public void AddLine(ProductId productId, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("No se pueden agregar líneas a una orden confirmada.");
_lines.Add(new OrderLine(productId, quantity, unitPrice));
}
public void Confirm()
{
if (!_lines.Any())
throw new InvalidOperationException("Una orden no puede confirmarse sin líneas.");
Status = OrderStatus.Confirmed;
}
}
[Fact]
public void Confirm_deberia_fallar_si_la_orden_no_tiene_lineas()
{
var order = new Order();
var action = () => order.Confirm();
action.Should().Throw<InvalidOperationException>();
}
Sin mocks, sin base de datos, sin frameworks de infraestructura. Solo xUnit (o NUnit) y, opcionalmente, FluentAssertions para legibilidad. Este tipo de test debería representar la mayor parte de tu suite: es el más barato de escribir, ejecutar y mantener.
Los casos de uso orquestan el dominio, pero dependen de puertos (interfaces) para hablar con el exterior. Aquí sí usamos dobles de prueba, pero con un límite claro: solo mockeamos los puertos que el propio caso de uso define, nunca detalles internos del dominio.
public class PlaceOrderUseCase
{
private readonly IOrderRepository _repository;
private readonly IClock _clock;
public PlaceOrderUseCase(IOrderRepository repository, IClock clock)
{
_repository = repository;
_clock = clock;
}
public async Task Handle(PlaceOrderCommand command)
{
var order = Order.Create(command.CustomerId, _clock.UtcNow);
foreach (var item in command.Items)
order.AddLine(item.ProductId, item.Quantity, item.UnitPrice);
order.Confirm();
await _repository.Save(order);
}
}
[Fact]
public async Task Handle_deberia_guardar_la_orden_confirmada()
{
var repository = Substitute.For<IOrderRepository>();
var clock = Substitute.For<IClock>();
clock.UtcNow.Returns(new DateTime(2026, 7, 1));
var useCase = new PlaceOrderUseCase(repository, clock);
var command = new PlaceOrderCommand(CustomerId.New(), new[] { new OrderItem(...) });
await useCase.Handle(command);
await repository.Received(1).Save(Arg.Is<Order>(o => o.Status == OrderStatus.Confirmed));
}
Con NSubstitute (o Moq si es lo que ya usas en el proyecto) sustituimos IOrderRepository e IClock porque son puertos: fronteras explícitas de la aplicación. No estamos testeando cómo se guarda la orden en la base de datos, solo que el caso de uso invoca correctamente al puerto con el estado correcto.
Un adaptador solo demuestra que funciona cuando se lo prueba contra la tecnología que envuelve. Un test unitario de OrderRepositoryEfCore con un mock de DbContext no te dice nada útil: lo que puede fallar en un repositorio son los detalles de mapeo, las queries, los índices, la serialización. Eso solo se descubre con infraestructura real o casi real.
public class OrderRepositoryEfCoreTests : IClassFixture<PostgresContainerFixture>
{
private readonly AppDbContext _context;
public OrderRepositoryEfCoreTests(PostgresContainerFixture fixture)
{
_context = fixture.CreateContext();
}
[Fact]
public async Task Save_deberia_persistir_la_orden_con_sus_lineas()
{
var repository = new OrderRepositoryEfCore(_context);
var order = Order.Create(CustomerId.New(), DateTime.UtcNow);
order.AddLine(ProductId.New(), 2, new Money(10, "USD"));
await repository.Save(order);
var stored = await _context.Orders
.Include(o => o.Lines)
.FirstAsync(o => o.Id == order.Id);
stored.Lines.Should().HaveCount(1);
}
}
En .NET, la herramienta natural para esto es Testcontainers: levanta un Postgres, SQL Server o Redis real en Docker durante el test y lo destruye al terminar. Es más lento que un unit test, pero infinitamente más confiable que un mock de DbContext. Para adaptadores de salida hacia APIs externas, el equivalente es WireMock.NET: simula el servicio remoto sin depender de que esté disponible durante el test.
Los controllers (o minimal API endpoints) son también adaptadores, pero de entrada. Aquí el objetivo es verificar que el pipeline completo —routing, model binding, validación, serialización— funciona de punta a punta, usando la aplicación real pero sustituyendo únicamente lo que cruza el borde del proceso (por ejemplo, la base de datos, si aún no queremos pagar ese costo).
public class OrdersControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrdersControllerTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task POST_orders_deberia_devolver_201_con_una_orden_valida()
{
var response = await _client.PostAsJsonAsync("/api/orders", new
{
customerId = Guid.NewGuid(),
items = new[] { new { productId = Guid.NewGuid(), quantity = 1, unitPrice = 25.0 } }
});
response.StatusCode.Should().Be(HttpStatusCode.Created);
}
}
Los e2e recorren el sistema completo, con la infraestructura real (o un ambiente de staging) y sin sustituir nada. Son los más lentos, los más frágiles y los más caros de mantener, así que se reservan para los tres o cuatro flujos de negocio que, si se rompen, cuestan dinero de verdad: crear una orden y cobrarla, por ejemplo. No se usan para cubrir casos límite; para eso ya tienes el dominio.
| Capa | Qué se testea | Tipo de test | Herramientas .NET |
|---|---|---|---|
| Dominio | Entidades, value objects, invariantes | Unitario, sin dobles | xUnit, FluentAssertions |
| Aplicación | Orquestación de casos de uso | Unitario, con dobles solo de puertos | xUnit, NSubstitute/Moq |
| Adaptadores de salida | Repositorios, gateways, clientes externos | Integración, tecnología real | Testcontainers, WireMock.NET |
| Adaptadores de entrada | Controllers, endpoints, pipeline HTTP | Integración, proceso completo | WebApplicationFactory |
| Sistema completo | Flujos de negocio críticos | End-to-end | Playwright, entorno de staging |
Esto importa especialmente cuando quien escribe el test es un agente de IA: sin una regla explícita, un agente tiende a resolver por el camino más fácil, que casi siempre es mockear todo. La buena noticia es que la arquitectura hexagonal ya te da las señales necesarias para decidir automáticamente, sin depender del criterio subjetivo de cada sesión.
La primera señal es la ubicación en el código. Si la carpeta o namespace es Domain/, la clase no debería tener ninguna dependencia externa: eso es un unit test sin dobles, sin excepción. Si es Application/, la clase depende de interfaces (puertos) definidas en esa misma capa: eso es un unit test con dobles limitados exactamente a esas interfaces. Si es Infrastructure/ o Adapters/, la clase implementa un puerto usando una tecnología concreta: eso es un integration test.
La segunda señal, más robusta que la carpeta, es de qué depende la clase. Si el constructor no recibe ninguna interfaz que cruce el borde del proceso (I/O, red, reloj del sistema, generador de GUIDs), es candidata a unit test puro. Si recibe interfaces pero ninguna implementación concreta de infraestructura, es candidata a unit test con dobles de esas interfaces. Si la clase misma es la que implementa esa interfaz usando DbContext, HttpClient o un SDK externo, ya no hay nada que mockear: es integration test por definición, porque el propósito del test es justamente validar esa integración.
Una tercera señal útil, sobre todo para prompts o instrucciones a un agente, es preguntarse: si este test falla, ¿qué se rompió? Si la respuesta es "una regla de negocio", el test pertenece al dominio. Si es "la orquestación entre pasos", pertenece a la aplicación. Si es "la forma en que hablamos con Postgres o con una API externa", pertenece a los adaptadores. Formular esta pregunta como regla explícita —en un archivo de convenciones del repositorio o en el system prompt del agente— evita el problema más común: agentes que generan un mock de DbContext para testear un caso de uso, cuando lo correcto era mockear el repositorio a nivel de puerto.
El más frecuente es el over-mocking del dominio: tests de entidades que reciben dobles de otras entidades o value objects, cuando en realidad esas colaboraciones deberían resolverse con objetos reales porque son baratos de construir y no tienen efectos secundarios. Si necesitas mockear algo dentro del dominio, generalmente el problema es de diseño, no de testing.
El segundo es la pirámide invertida: demasiados e2e y pocos unit tests. Sucede cuando el equipo no confía en que los mocks de los puertos reflejen el comportamiento real, así que termina probando todo contra el sistema completo. La solución no es eliminar los e2e, sino invertir en integration tests de adaptadores que sí verifiquen el contrato real de esos puertos.
El tercero es escribir integration tests disfrazados de unitarios: tests que instancian un DbContext real con SQLite in-memory y lo llaman "unit test" porque corre rápido. Es una decisión válida como estrategia intermedia, pero conviene ser honesto en la clasificación: si toca infraestructura, es integración, aunque sea rápida.
La arquitectura hexagonal no solo organiza el código, también organiza la estrategia de testing: cada capa impone naturalmente qué tipo de test le corresponde, si sabemos leer las señales correctas (ubicación, dependencias, y qué se rompe cuando el test falla). Esta claridad es la base para el siguiente paso de la serie: adaptar el ciclo clásico de TDD —rojo, verde, refactor— a un flujo inside-out que evite que un agente de IA llene el proyecto de mocks innecesarios, empezando siempre por el dominio.
Cuando los desarrolladores comienzan a estudiar Arquitectura Hexagonal, suelen comprender rápidamente conceptos como dominio, puertos y adaptadores. Sin embargo, existe un patrón que aparece una y otra vez en prácticamente todas las implementaciones exitosas: el Repository.
A pesar de su popularidad, el patrón Repository suele ser malinterpretado. En algunos proyectos se convierte en una simple envoltura alrededor de Entity Framework Core. En otros, termina transformándose en una capa genérica que agrega complejidad sin aportar valor real.
La intención original del patrón es mucho más importante. Repository existe para proteger al dominio de los detalles de persistencia y permitir que la lógica de negocio evolucione independientemente de la tecnología utilizada para almacenar datos.
En un mundo donde los agentes de IA generan código constantemente, esta separación se vuelve aún más relevante. Cuanto más claras sean las fronteras entre negocio e infraestructura, más fácil será mantener la calidad arquitectónica del sistema.
Imaginemos una aplicación ASP.NET Core donde un servicio de negocio accede directamente a Entity Framework Core.
public class OrderService
{
private readonly ApplicationDbContext _dbContext;
public OrderService(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task CreateOrder(Order order)
{
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync();
}
}
Este enfoque funciona perfectamente al inicio. La implementación es simple y fácil de comprender. El problema aparece cuando el sistema comienza a crecer.
Ahora el dominio depende directamente de Entity Framework Core. Si decidimos migrar a otro mecanismo de persistencia, la lógica de negocio deberá modificarse. Si queremos ejecutar pruebas aisladas, necesitaremos simular componentes específicos de la infraestructura. Si aparece un nuevo requisito tecnológico, el impacto puede extenderse por múltiples capas del sistema.
En este escenario, la base de datos deja de ser un detalle técnico y comienza a influir directamente sobre el diseño del negocio.
Eso es precisamente lo que la Arquitectura Hexagonal intenta evitar.
El patrón Repository propone una separación sencilla: el dominio no debería saber cómo se almacenan los datos. Su única preocupación debería ser acceder y persistir entidades mediante contratos bien definidos.
Desde la perspectiva del dominio, obtener un cliente o guardar una orden son operaciones del negocio. Los detalles sobre SQL Server, PostgreSQL, MongoDB o cualquier otra tecnología son responsabilidades de infraestructura.
Por esa razón, el dominio define una interfaz:
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id);
Task SaveAsync(Order order);
}
Este contrato representa un puerto dentro de la Arquitectura Hexagonal. El dominio expresa qué necesita, pero no cómo debe implementarse.
Una vez definido el puerto, la infraestructura puede proporcionar una implementación concreta.
Por ejemplo:
public class EfOrderRepository : IOrderRepository
{
private readonly ApplicationDbContext _dbContext;
public EfOrderRepository(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Order?> GetByIdAsync(OrderId id)
{
return await _dbContext.Orders.FindAsync(id.Value);
}
public async Task SaveAsync(Order order)
{
_dbContext.Update(order);
await _dbContext.SaveChangesAsync();
}
}
La lógica de negocio continúa utilizando únicamente la interfaz. La implementación concreta queda encapsulada dentro de la infraestructura.
Esto significa que el dominio nunca necesita conocer Entity Framework Core, DbContext, consultas SQL o detalles específicos del proveedor de datos.
Muchos desarrolladores escuchan la clásica frase "podrás cambiar de base de datos fácilmente" y concluyen que se trata de un caso poco frecuente.
Y tienen razón. La mayoría de los proyectos no cambian de motor de base de datos cada seis meses.
Sin embargo, el verdadero valor del Repository no está en la migración en sí misma. El valor está en que la lógica del negocio permanece aislada de cualquier decisión tecnológica.
Supongamos que una organización decide migrar parte de sus servicios desde SQL Server hacia PostgreSQL por motivos de costo o estrategia tecnológica.
Si el dominio depende directamente de Entity Framework Core y contiene consultas específicas, el esfuerzo de migración puede ser considerable.
Si la persistencia está encapsulada detrás de repositorios, el impacto se concentra principalmente en la infraestructura.
La lógica del negocio continúa funcionando exactamente igual.
Uno de los aspectos más interesantes del patrón Repository en la era de la IA es que actúa como un mecanismo de protección arquitectónica.
Cuando un agente recibe instrucciones ambiguas, suele mezclar responsabilidades. Es común encontrar implementaciones donde el acceso a datos aparece directamente dentro de controladores, servicios de aplicación o incluso entidades de dominio.
Por ejemplo:
public async Task ApproveOrder(Guid id)
{
var order = await _dbContext.Orders.FindAsync(id);
order.Approve();
await _dbContext.SaveChangesAsync();
}
Aunque el código funciona, introduce una dependencia directa entre la lógica del negocio y la infraestructura.
Cuando existe un contrato explícito mediante Repository, el agente tiene menos libertad para romper las reglas arquitectónicas.
El flujo esperado se vuelve evidente:
Caso de Uso
↓
Repository
↓
Infraestructura
La arquitectura proporciona límites claros y reduce la probabilidad de generar código acoplado.
A pesar de ser un patrón ampliamente conocido, existen varios errores que aparecen con frecuencia.
Es habitual encontrar implementaciones como esta:
public interface IRepository<T>
{
Task Add(T entity);
Task Update(T entity);
Task Delete(T entity);
}
Aunque parece una solución elegante, suele terminar ocultando necesidades específicas del dominio.
Los repositorios deberían expresar operaciones significativas para el negocio y no únicamente operaciones CRUD genéricas.
Otro error frecuente consiste en exponer IQueryable desde los repositorios.
IQueryable<Order> GetOrders();
Esto permite que las consultas se construyan fuera del repositorio, filtrando detalles de persistencia hacia otras capas.
En la práctica, el dominio vuelve a quedar acoplado a la tecnología de acceso a datos.
Si cada método del repositorio refleja exactamente un método de Entity Framework Core, probablemente el patrón no esté aportando ningún beneficio real.
El objetivo no es esconder DbContext detrás de otra clase. El objetivo es proteger al dominio de los detalles de persistencia.
Imaginemos un caso de uso para aprobar una orden.
La lógica de aplicación podría verse así:
public class ApproveOrderUseCase
{
private readonly IOrderRepository _repository;
public ApproveOrderUseCase(IOrderRepository repository)
{
_repository = repository;
}
public async Task Execute(OrderId id)
{
var order = await _repository.GetByIdAsync(id);
order.Approve();
await _repository.SaveAsync(order);
}
}
Observemos que el caso de uso no sabe si los datos provienen de SQL Server, PostgreSQL, MongoDB o una API externa.
Su única preocupación es aplicar reglas del negocio.
Eso es exactamente lo que buscamos en una arquitectura bien desacoplada.
Los agentes generan mejores resultados cuando existen contratos claros. Un repositorio bien diseñado reduce la cantidad de decisiones ambiguas y proporciona una estructura predecible para el desarrollo.
Cuando una IA recibe instrucciones como:
Implementa un nuevo caso de uso respetando
Arquitectura Hexagonal y utilizando IOrderRepository
para acceder a la persistencia.
Las probabilidades de obtener una implementación consistente aumentan considerablemente.
El agente comprende dónde debe vivir la lógica, dónde debe acceder a los datos y cuáles son los límites que no debe cruzar.
La arquitectura deja de depender exclusivamente de revisiones manuales y comienza a formar parte del propio proceso de generación.
El patrón Repository no existe para ocultar una base de datos ni para crear capas adicionales de abstracción. Su propósito principal es proteger al dominio de los detalles tecnológicos que inevitablemente cambiarán con el tiempo.
Al definir contratos claros entre negocio e infraestructura, Repository permite que las reglas del dominio permanezcan estables mientras la tecnología evoluciona alrededor de ellas.
Esta separación siempre ha sido valiosa, pero adquiere una importancia especial en la era de la inteligencia artificial. Los agentes generan código con gran velocidad, pero necesitan límites claros para producir soluciones sostenibles.
Un repositorio bien diseñado actúa como uno de esos límites. Ayuda a preservar la arquitectura, mantiene el dominio independiente y proporciona los guardarraíles necesarios para que tanto desarrolladores como agentes puedan trabajar de forma consistente a largo plazo.