Wednesday, July 1, 2026

Construcción del backend con TDD: el agente como copiloto en el ciclo rojo-verde-refactor

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:

  1. 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".
  2. 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).
  3. 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.
  4. El agente implementa el mínimo código necesario para pasar a verde. Nada de anticipar funcionalidad que el test todavía no pide.
  5. 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.
  6. 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.

Inside-out TDD con agentes: cómo evitar que la IA llene tu backend de mocks

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.

Por qué el TDD clásico necesita adaptarse

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.

Las reglas de inside-out TDD para evitar que la IA llene todo de mocks

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

Ciclo práctico: dominio → casos de uso → adaptadores

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.

Paso 1 — Dominio: la primera regla de negocio

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.

Paso 2 — Aplicación: aquí aparece el primer mock legítimo

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.

Paso 3 — Adaptadores: se valida el puerto contra la tecnología real

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.

Paso 4 — Cerrando el ciclo: el puerto de entrada

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.

Cierre

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.

Estrategia de testing en arquitectura hexagonal: qué testear, dónde y con qué herramienta

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.

Repaso rápido: las capas de la arquitectura hexagonal

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.

La pirámide de testing mapeada a las capas hexagonales

Dominio: unit tests puros, sin mocks

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.

Aplicación: unit tests con dobles de los puertos

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.

Adaptadores: integration tests contra la tecnología real

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.

Puertos de entrada: integration tests con WebApplicationFactory

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

End-to-end: pocos, y solo para flujos críticos

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.

Tabla de decisión

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

Cómo saber qué tipo de test escribir en cada capa

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.

Errores comunes

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.

Cierre

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.

Wednesday, June 24, 2026

El patrón Repository: la pieza que mantiene al dominio independiente de la base de datos

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.

El problema que Repository intenta resolver

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.

La idea fundamental detrás de Repository

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.

La infraestructura implementa el contrato

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.

El verdadero beneficio: cambiar la persistencia sin tocar el negocio

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.

Repository como guardarraíl para la IA

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.

Los errores más comunes al implementar Repository

A pesar de ser un patrón ampliamente conocido, existen varios errores que aparecen con frecuencia.

Crear repositorios genéricos para todo

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.

Filtrar entidades mediante IQueryable

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.

Convertir el repositorio en una copia de DbContext

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.

Un ejemplo práctico en Arquitectura Hexagonal

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.

Cómo ayuda Repository cuando trabajamos con IA

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.

Conclusió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.

Ports and Adapters desde cero: entendiendo realmente la Arquitectura Hexagonal

Cuando los desarrolladores escuchan por primera vez términos como Arquitectura Hexagonal, Ports and Adapters o Hexagonal Architecture, es común pensar que se trata simplemente de otra variante de las arquitecturas por capas. Después de todo, en el ecosistema .NET ya existen múltiples enfoques para organizar aplicaciones: N-Tier, Onion Architecture, Clean Architecture y otras alternativas similares.

Sin embargo, la propuesta original de Alistair Cockburn no nació para resolver un problema de organización de carpetas ni para definir una estructura específica de proyectos. Su objetivo era mucho más profundo: evitar que la lógica de negocio quedara contaminada por dependencias externas.

Este problema sigue siendo relevante hoy, pero adquiere una importancia aún mayor cuando utilizamos herramientas de inteligencia artificial para generar código. Si no existen límites claros, los agentes tienden a mezclar responsabilidades y a introducir dependencias donde no deberían existir.

Por esa razón, antes de hablar de puertos, adaptadores o repositorios, conviene entender qué problema intentaba resolver Cockburn y por qué su propuesta sigue siendo tan vigente.

El descubrimiento de Alistair Cockburn

A principios de los años 2000, Cockburn observó un patrón que se repetía constantemente en muchos proyectos. La lógica de negocio terminaba mezclada con interfaces gráficas, bases de datos, frameworks y sistemas externos.

Inicialmente, el problema no parecía grave. Las aplicaciones funcionaban y las funcionalidades podían implementarse relativamente rápido. Sin embargo, con el tiempo aparecían dificultades para realizar pruebas automatizadas, reemplazar tecnologías o adaptar el sistema a nuevos requisitos.

Cada cambio en la infraestructura terminaba afectando directamente al negocio. Una modificación en la base de datos requería cambios en múltiples servicios. Un nuevo proveedor externo obligaba a alterar componentes críticos. Las dependencias se propagaban por todo el sistema.

Cockburn llegó a una conclusión importante: el verdadero problema no estaba en la tecnología utilizada. El problema era que el núcleo del negocio dependía de elementos que deberían ser secundarios.

Su propuesta fue simple pero poderosa: colocar el dominio en el centro y tratar todo lo demás como algo externo.

¿Por qué se llama Arquitectura Hexagonal?

Uno de los aspectos más curiosos de esta arquitectura es que el hexágono no tiene un significado técnico especial. De hecho, el propio Cockburn explicó en varias ocasiones que eligió esa forma simplemente para evitar que las personas interpretaran el diagrama como una arquitectura por capas tradicional.

Lo importante no son los seis lados del dibujo. Lo importante es la idea de que existen múltiples formas de interactuar con el sistema.

Un usuario puede acceder mediante una API REST. Otro proceso puede utilizar una cola de mensajes. Un administrador podría trabajar mediante una interfaz web. Todos ellos son mecanismos de entrada hacia el mismo dominio.

De la misma forma, el sistema puede comunicarse con SQL Server, MongoDB, Redis, Azure Service Bus o cualquier otra tecnología externa.

El hexágono representa precisamente esa independencia entre el negocio y los mecanismos utilizados para conectarse con él.

Qué diferencia a Ports and Adapters de Clean Architecture

Muchos desarrolladores observan diagramas de Arquitectura Hexagonal y Clean Architecture y concluyen que son exactamente lo mismo. Aunque comparten varios principios fundamentales, existe una diferencia importante en su origen y enfoque.

Clean Architecture, popularizada por Robert C. Martin, se enfoca en la dirección de las dependencias y en la separación entre capas. La regla principal establece que las dependencias siempre deben apuntar hacia el núcleo del sistema.

La Arquitectura Hexagonal pone el énfasis en las fronteras del dominio y en la interacción con el exterior mediante contratos explícitos.

En términos prácticos, ambas arquitecturas suelen conducir a diseños muy similares. Sin embargo, Ports and Adapters ofrece una forma especialmente clara de pensar en integraciones externas, algo que resulta extremadamente útil cuando trabajamos con agentes de IA.

Mientras más explícitos sean los límites, más fácil será para un agente respetarlos durante la generación de código.

El dominio como una DMZ

Una analogía útil para comprender el dominio es pensar en él como una zona desmilitarizada o DMZ, similar a las utilizadas en redes corporativas.

La DMZ actúa como una frontera protegida entre el mundo externo y los sistemas internos. Todo lo que entra debe cumplir ciertas reglas. Todo lo que sale pasa por mecanismos controlados.

El dominio debería funcionar exactamente de la misma manera.

Las reglas de negocio, entidades, objetos de valor y casos de uso viven dentro de esa frontera protegida. Ningún detalle relacionado con frameworks, bases de datos, APIs o bibliotecas externas debería infiltrarse en ese espacio.

Cuando el dominio permanece aislado, las reglas de negocio pueden evolucionar independientemente de la tecnología utilizada para exponerlas o almacenarlas.

Qué puede entrar al dominio

Dentro del dominio deberían existir únicamente conceptos relacionados con el negocio.

Por ejemplo, en una plataforma de comercio electrónico podríamos encontrar:

  • Order
  • Customer
  • Product
  • Money
  • OrderService
  • Reglas de validación del negocio

Todos estos elementos representan conocimiento del dominio. Si mañana migramos de SQL Server a PostgreSQL o de ASP.NET Core a otro framework, estas piezas deberían permanecer prácticamente intactas.

Esa estabilidad es precisamente lo que buscamos proteger.

Qué no debería entrar al dominio

Aquí es donde muchos proyectos comienzan a desviarse.

Es frecuente encontrar entidades de dominio que conocen detalles de Entity Framework Core, atributos específicos de serialización, validaciones ligadas a bibliotecas externas o dependencias directas hacia servicios de infraestructura.

Por ejemplo:


public class Customer
{
    [Required]
    [MaxLength(100)]
    public string Name { get; set; }

    public DbSet<Order> Orders { get; set; }
}

Aunque este código parece inocente, está mezclando conceptos de negocio con detalles de persistencia y validación.

Con el tiempo, estas dependencias se acumulan y hacen que el dominio dependa cada vez más de tecnologías externas.

El error del 90% con las librerías de validación

Uno de los errores más comunes en proyectos modernos consiste en introducir bibliotecas de validación directamente dentro del dominio.

En el ecosistema .NET es frecuente utilizar herramientas como FluentValidation para validar solicitudes o comandos. Estas bibliotecas son extremadamente útiles y resuelven problemas reales.

El problema aparece cuando las reglas del negocio comienzan a depender de ellas.

Por ejemplo, es habitual encontrar algo como esto:


public class CustomerValidator : AbstractValidator<Customer>
{
    public CustomerValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty();
    }
}

La pregunta importante es: ¿la regla pertenece al negocio o pertenece a FluentValidation?

Si el dominio necesita una biblioteca externa para expresar sus reglas, entonces el dominio ya no es completamente independiente.

La validación técnica y la validación de negocio son conceptos distintos. Una biblioteca puede ayudar a validar formatos, tamaños o estructuras de entrada. Sin embargo, las reglas fundamentales del negocio deberían poder existir incluso si mañana decidimos eliminar dicha biblioteca.

Cuando esta separación no existe, el dominio se convierte en una bomba de relojería. Cada nueva dependencia aumenta el costo de evolución del sistema.

Entendiendo los Ports

Los puertos representan contratos definidos por el dominio para comunicarse con el exterior.

Por ejemplo:


public interface ICustomerRepository
{
    Task<Customer> GetByIdAsync(CustomerId id);
    Task SaveAsync(Customer customer);
}

El dominio no sabe si los datos provienen de SQL Server, PostgreSQL, MongoDB o un servicio remoto. Lo único que conoce es el contrato.

Ese contrato constituye el puerto.

Entendiendo los Adapters

Los adaptadores son las implementaciones concretas de esos contratos.

Por ejemplo:


public class SqlCustomerRepository : ICustomerRepository
{
    // Implementación con Entity Framework Core
}

O también:


public class MongoCustomerRepository : ICustomerRepository
{
    // Implementación con MongoDB
}

Desde la perspectiva del dominio, ambos adaptadores son equivalentes porque cumplen el mismo contrato.

Esto permite modificar tecnologías externas sin alterar la lógica de negocio.

Por qué este enfoque funciona tan bien con IA

Los agentes de IA funcionan mejor cuando existen límites claros. Cuanto más ambiguas son las responsabilidades, más probable es que aparezcan dependencias innecesarias y código acoplado.

La Arquitectura Hexagonal proporciona un conjunto de reglas extremadamente simples:

  • El dominio está en el centro.
  • El dominio no depende de infraestructura.
  • La comunicación ocurre mediante puertos.
  • Los detalles tecnológicos viven en adaptadores.

Estas restricciones reducen enormemente el espacio de decisiones y ayudan a que la IA produzca implementaciones más consistentes.

Conclusión

Ports and Adapters no es simplemente una forma diferente de organizar carpetas o proyectos. Es una estrategia para proteger el conocimiento más importante de una aplicación: las reglas del negocio.

Alistair Cockburn entendió que los frameworks, bases de datos y tecnologías cambian constantemente, mientras que las reglas centrales del negocio suelen permanecer estables durante años.

Por eso propuso colocar el dominio en el centro y aislarlo mediante puertos y adaptadores. Esta separación no solo mejora la mantenibilidad y la capacidad de prueba. También proporciona los límites claros que los agentes de IA necesitan para generar código sostenible a largo plazo.

Una vez comprendidos estos conceptos, el siguiente paso natural es explorar uno de los patrones más importantes dentro de esta arquitectura: el Repository. Allí veremos cómo desacoplar completamente la lógica de negocio de la persistencia y por qué este patrón sigue siendo fundamental en sistemas modernos construidos con .NET.

Monday, June 22, 2026

Por qué la arquitectura importa con IA

La inteligencia artificial está cambiando la forma en que construimos software. Hoy es posible generar controladores, servicios, repositorios, pruebas unitarias e incluso propuestas completas de arquitectura en cuestión de segundos. Sin embargo, esta velocidad también introduce un nuevo riesgo: producir grandes cantidades de código incorrecto a una velocidad sin precedentes.

Muchos desarrolladores están descubriendo que la calidad del software generado por IA depende menos del modelo utilizado y más de la arquitectura sobre la cual se construye el sistema. Cuando la arquitectura es clara, las herramientas de IA tienden a producir resultados consistentes. Cuando la arquitectura es difusa o inexistente, la IA suele amplificar los problemas existentes.

Por esta razón, antes de hablar de prompts, agentes o generación de código, es necesario hablar de arquitectura. En un entorno donde una parte importante del código puede ser producida automáticamente, la arquitectura deja de ser un detalle técnico y se convierte en el principal mecanismo de control.

El problema de dejar que un agente programe sin reglas claras

Imaginemos que le pedimos a una IA que implemente una nueva funcionalidad en una aplicación ASP.NET Core:


Implementa el caso de uso para registrar un cliente.

La solicitud parece razonable, pero deja demasiadas decisiones abiertas. El agente deberá decidir dónde colocar la lógica, cómo validar los datos, cómo acceder a la base de datos y cómo manejar errores. En ausencia de restricciones explícitas, tomará decisiones basadas en los patrones más frecuentes encontrados durante su entrenamiento.

El resultado suele funcionar desde una perspectiva técnica. El código compila, las pruebas básicas pasan y la funcionalidad parece correcta. Sin embargo, es común encontrar lógica de negocio dentro de controladores, dependencias directas hacia Entity Framework, validaciones dispersas y un fuerte acoplamiento entre capas.

El problema no es que la IA haya cometido un error. El problema es que nunca recibió reglas claras sobre cómo debía construir la solución.

Cuando un desarrollador senior incorpora a un nuevo miembro al equipo, normalmente le explica la arquitectura, los patrones utilizados y las convenciones del proyecto. Con los agentes ocurre exactamente lo mismo. La diferencia es que la IA ejecutará las instrucciones con una velocidad mucho mayor, tanto para bien como para mal.

La IA genera código acoplado por defecto

Existe una característica importante que muchos equipos descubren después de utilizar IA durante varios meses: los modelos tienden a generar soluciones fuertemente acopladas cuando no reciben restricciones arquitectónicas.

Esto ocurre porque las implementaciones más comunes encontradas en internet suelen priorizar la rapidez sobre la separación de responsabilidades. Como consecuencia, es frecuente obtener ejemplos donde la lógica de negocio depende directamente de frameworks, bibliotecas externas o detalles de infraestructura.

Por ejemplo, una implementación típica podría verse así:


public async Task<IActionResult> Create(CreateOrderRequest request)
{
    var order = new Order();

    if(request.Items.Count == 0)
        return BadRequest();

    _dbContext.Orders.Add(order);

    await _dbContext.SaveChangesAsync();

    return Ok();
}

A primera vista no parece haber ningún problema. Sin embargo, la lógica de negocio, las validaciones, el acceso a datos y la capa web están mezclados dentro de la misma operación.

Cuando este patrón se replica cientos o miles de veces dentro de una aplicación, aparecen dificultades para realizar pruebas, evolucionar el dominio o reemplazar tecnologías específicas.

La IA no está intentando sabotear la arquitectura. Simplemente está optimizando para producir una solución funcional utilizando los patrones más frecuentes disponibles.

El verdadero riesgo no es el código incorrecto

Cuando se habla de IA en desarrollo de software, muchas conversaciones se enfocan en errores sintácticos o defectos funcionales. Sin embargo, esos problemas suelen ser relativamente fáciles de detectar mediante pruebas automatizadas y revisiones de código.

El riesgo más importante es otro: la erosión gradual de la arquitectura.

Cada vez que una nueva funcionalidad introduce dependencias innecesarias, mezcla responsabilidades o viola límites entre capas, la complejidad del sistema aumenta ligeramente. Individualmente, estos cambios parecen insignificantes. Acumulados durante meses o años, terminan convirtiéndose en una deuda técnica difícil de revertir.

La IA puede acelerar enormemente este proceso porque produce cambios con una velocidad mucho mayor que un desarrollador trabajando manualmente.

Por eso, el principal desafío no consiste en evitar que la IA genere código. Consiste en asegurarse de que el código generado respete las reglas arquitectónicas del sistema.

La arquitectura se convierte en un conjunto de guardarraíles

Una forma útil de entender la relación entre arquitectura e IA es pensar en la arquitectura como un sistema de guardarraíles.

Los guardarraíles no indican exactamente qué camino seguir. Su función es impedir que salgamos de la carretera.

Cuando una arquitectura está bien definida, limita las decisiones que pueden tomar tanto los desarrolladores como los agentes. Esto reduce la variabilidad de las implementaciones y aumenta la consistencia del sistema.

Por ejemplo, si la arquitectura establece que:

  • La lógica de negocio vive en el dominio.
  • El dominio no depende de frameworks.
  • La persistencia se accede mediante interfaces.
  • La infraestructura implementa contratos definidos por el dominio.

Entonces cualquier implementación generada por IA debe respetar esas reglas. El espacio de posibles soluciones se reduce y los resultados se vuelven mucho más predecibles.

Por qué la Arquitectura Hexagonal funciona especialmente bien con IA

Existen muchas arquitecturas válidas dentro del ecosistema .NET. Sin embargo, la Arquitectura Hexagonal presenta una característica especialmente interesante para los equipos que utilizan IA generativa: define fronteras extremadamente claras.

En una arquitectura hexagonal, el dominio ocupa el centro del sistema. Todo lo demás —bases de datos, APIs, colas de mensajes, frameworks y bibliotecas externas— se considera un detalle de infraestructura.

Esta separación proporciona instrucciones muy concretas para los agentes:

  • La lógica de negocio pertenece al dominio.
  • La infraestructura no define reglas de negocio.
  • Las dependencias apuntan hacia el interior.
  • Las integraciones externas se realizan mediante puertos y adaptadores.

Estas restricciones facilitan enormemente la generación de código consistente. En lugar de improvisar dónde colocar cada responsabilidad, el agente dispone de un conjunto claro de límites arquitectónicos.

Un ejemplo práctico en .NET

Supongamos que necesitamos agregar soporte para un nuevo proveedor de pagos en una aplicación de comercio electrónico.

En una arquitectura fuertemente acoplada, la lógica de integración suele terminar dispersa entre servicios, controladores y repositorios. Cada cambio implica modificar múltiples componentes.

En una arquitectura hexagonal, el dominio define un contrato:


public interface IPaymentGateway
{
    Task ProcessPaymentAsync(Payment payment);
}

La implementación concreta se ubica en infraestructura:


public class StripePaymentGateway : IPaymentGateway
{
    // Implementación específica
}

Cuando la IA recibe este contexto, entiende inmediatamente dónde debe colocar cada pieza. El dominio permanece estable y la infraestructura puede evolucionar sin afectar las reglas de negocio.

La arquitectura proporciona las restricciones necesarias para que la generación automática siga siendo sostenible a largo plazo.

La arquitectura es más importante que nunca

Durante años, muchos equipos consideraron la arquitectura como una preocupación secundaria frente a la implementación. En la era de la IA ocurre exactamente lo contrario.

La capacidad de producir código se está volviendo cada vez más barata. Lo que sigue siendo costoso es mantener sistemas comprensibles, evolutivos y alineados con las necesidades del negocio.

La arquitectura es el mecanismo que permite preservar esas propiedades cuando la velocidad de desarrollo aumenta drásticamente.

Cuanto más código pueda generar un agente, más importante será contar con límites claros que definan dónde debe vivir cada responsabilidad.

Conclusión

La inteligencia artificial no elimina la necesidad de una buena arquitectura. En realidad, la vuelve aún más importante.

Los agentes son extremadamente eficaces generando soluciones dentro de un marco de trabajo bien definido. Sin embargo, cuando las reglas son ambiguas o inexistentes, tienden a producir sistemas cada vez más acoplados y difíciles de mantener.

Por esta razón, la arquitectura debe considerarse el primer paso antes de generar una sola línea de código. Una buena arquitectura no limita el potencial de la IA; lo amplifica. Proporciona los guardarraíles necesarios para que la velocidad de ejecución no termine convirtiéndose en deuda técnica.

Y entre las distintas alternativas disponibles, la Arquitectura Hexagonal destaca porque ofrece precisamente lo que los agentes necesitan para trabajar de forma predecible: límites claros, responsabilidades bien definidas y una separación rigurosa entre el dominio y la infraestructura.

Friday, June 19, 2026

Stack y flujo paralelo: cómo trabajar en múltiples proyectos sin perder el control

Durante años, la mayoría de los desarrolladores hemos trabajado bajo una premisa simple: concentrarnos en una tarea a la vez. Terminamos una funcionalidad, luego pasamos a la siguiente. Terminamos un proyecto, luego iniciamos otro. Este enfoque ha funcionado razonablemente bien porque el desarrollo de software siempre estuvo limitado por la velocidad a la que una persona podía analizar problemas, escribir código y validar soluciones.

La aparición de herramientas basadas en inteligencia artificial está modificando esa realidad. Hoy es posible producir código, documentación, pruebas y propuestas de diseño a una velocidad muy superior a la que era común hace apenas unos años. Como consecuencia, muchos desarrolladores están descubriendo que pueden avanzar simultáneamente en varios frentes sin experimentar la misma carga cognitiva que antes.

Sin embargo, trabajar en paralelo no significa simplemente abrir más ventanas del IDE o participar en más proyectos al mismo tiempo. De hecho, hacerlo sin una estrategia clara suele generar el efecto contrario: más interrupciones, pérdida de contexto y una creciente sensación de desorden. La verdadera ventaja aparece cuando existe un sistema que permita mantener el control mientras se incrementa la capacidad de ejecución.

El problema del enfoque secuencial

En muchos equipos, el flujo de trabajo continúa siendo esencialmente lineal. Un desarrollador recibe una tarea, la analiza, la implementa, la prueba y finalmente pasa a la siguiente. Cuando aparece un bloqueo, gran parte del trabajo queda detenido hasta encontrar una solución.

Este modelo tiene sentido cuando la capacidad de producir código es el principal factor limitante. Sin embargo, cuando contamos con asistentes capaces de generar implementaciones, pruebas unitarias, consultas SQL o documentación en segundos, el cuello de botella se desplaza hacia otras actividades como la toma de decisiones, la validación y la gestión del contexto.

En otras palabras, la velocidad para escribir código deja de ser el principal desafío. El nuevo desafío consiste en coordinar múltiples flujos de trabajo sin perder visibilidad sobre lo que está ocurriendo en cada uno de ellos.

Qué significa realmente trabajar en paralelo

Trabajar en paralelo no implica realizar varias actividades al mismo tiempo de forma caótica. Significa mantener múltiples iniciativas avanzando de manera controlada, aprovechando los tiempos muertos y reduciendo los períodos de espera.

Por ejemplo, un desarrollador senior podría estar trabajando simultáneamente en:

  • Una nueva funcionalidad para una API ASP.NET Core.
  • La modernización de un sistema legado.
  • La documentación técnica de un servicio interno.
  • La investigación de una nueva arquitectura.

Tradicionalmente, gestionar estas actividades requería cambios constantes de contexto que resultaban costosos. Con IA, muchas tareas pueden delegarse temporalmente mientras el desarrollador continúa avanzando en otros frentes.

El objetivo no es aumentar la cantidad de trabajo, sino reducir el tiempo improductivo entre una actividad y otra.

El nuevo cuello de botella: el contexto

Cuando un desarrollador trabaja en varios proyectos, el problema más frecuente no es la implementación. El problema es recordar dónde quedó cada iniciativa, cuáles fueron las decisiones tomadas y qué restricciones existen en cada sistema.

Muchos equipos intentan resolver esto confiando en la memoria individual. Sin embargo, este enfoque se vuelve insostenible cuando el número de proyectos aumenta.

La IA amplifica este problema porque acelera la ejecución. Es posible avanzar más rápido, pero también es posible perder contexto más rápidamente si no existe una estructura adecuada.

Por esta razón, los desarrolladores más efectivos están comenzando a tratar el contexto como un activo del proyecto y no como un conocimiento almacenado únicamente en la mente de las personas.

Construyendo un stack de trabajo sostenible

Cuando hablamos de stack en este contexto, no nos referimos únicamente a tecnologías como .NET, SQL Server o Azure. Nos referimos al conjunto de herramientas, documentos y procesos que permiten gestionar el conocimiento necesario para desarrollar software de manera consistente.

Un stack de trabajo sostenible suele incluir varios elementos complementarios.

Documentación de arquitectura

Cada proyecto debería contar con una descripción clara de su arquitectura. No es necesario producir documentación extensa ni diagramas complejos. Lo importante es registrar las decisiones fundamentales para que cualquier persona —o cualquier agente de IA— pueda comprender rápidamente cómo está construido el sistema.

Por ejemplo:

  • Patrón arquitectónico utilizado.
  • Dependencias principales.
  • Reglas de comunicación entre capas.
  • Convenciones de diseño relevantes.

Registro de decisiones técnicas

Muchas veces el problema no es entender cómo funciona una solución, sino comprender por qué fue construida de determinada manera.

Mantener un historial simple de decisiones técnicas evita que las mismas discusiones se repitan constantemente y proporciona contexto valioso cuando se retoma un proyecto después de varias semanas.

Por ejemplo:

  • Por qué se eligió MediatR.
  • Por qué se descartó Event Sourcing.
  • Por qué se utiliza una determinada estrategia de caché.

Prompts reutilizables

A medida que los equipos incorporan IA en su flujo de trabajo, comienzan a aparecer patrones de interacción repetitivos. En lugar de escribir instrucciones desde cero cada vez, resulta útil construir una biblioteca de prompts reutilizables.

Por ejemplo:

  • Generación de pruebas unitarias.
  • Revisión de código.
  • Análisis de arquitectura.
  • Creación de documentación técnica.

Esto reduce la variabilidad de los resultados y mejora la consistencia entre proyectos.

Cómo mantener el control mientras aumenta la velocidad

Uno de los mayores riesgos al trabajar con IA es avanzar demasiado rápido sin verificar adecuadamente lo que se está produciendo. La velocidad puede generar una falsa sensación de progreso.

Por esta razón, resulta importante separar claramente las actividades de generación y validación.

La IA puede generar propuestas, implementaciones o pruebas. Sin embargo, la validación debe seguir siendo una responsabilidad explícita del desarrollador.

Un flujo saludable podría ser:

  1. Definir el problema.
  2. Solicitar alternativas de solución.
  3. Generar una implementación inicial.
  4. Revisar la propuesta críticamente.
  5. Ejecutar pruebas automatizadas.
  6. Validar el comportamiento esperado.

Este enfoque permite mantener la calidad sin sacrificar velocidad.

Un ejemplo práctico en un equipo .NET

Imaginemos un equipo que mantiene varios productos basados en ASP.NET Core. Mientras una nueva funcionalidad está siendo implementada, también es necesario corregir defectos en producción, mejorar la observabilidad del sistema y evaluar una futura migración tecnológica.

Con un enfoque tradicional, cada iniciativa compite por la atención de los desarrolladores. Con un flujo paralelo bien estructurado, las tareas pueden avanzar simultáneamente mediante ciclos cortos de trabajo apoyados por IA.

Mientras se revisa una propuesta de arquitectura para una migración, la IA puede ayudar a generar pruebas para una funcionalidad en desarrollo. Mientras esas pruebas se ejecutan, puede colaborar en la documentación de un servicio existente. El desarrollador mantiene el control de las decisiones importantes mientras delega actividades de menor valor estratégico.

El resultado no es simplemente más velocidad. El resultado es una mejor utilización del tiempo disponible.

La ventaja competitiva no es programar más rápido

Muchas conversaciones sobre IA se centran exclusivamente en la generación de código. Sin embargo, la verdadera transformación está ocurriendo en la forma en que organizamos el trabajo.

Los desarrolladores que obtendrán mejores resultados no serán necesariamente quienes generen más líneas de código por hora. Serán aquellos que construyan sistemas de trabajo capaces de coordinar múltiples iniciativas, preservar contexto y mantener estándares de calidad incluso cuando la velocidad de ejecución aumente.

La capacidad de gestionar conocimiento se está convirtiendo en una habilidad tan importante como la capacidad de programar.

Conclusión

La inteligencia artificial está reduciendo significativamente el costo de producir software. Como consecuencia, muchas de las limitaciones tradicionales del desarrollo están desapareciendo. Sin embargo, nuevos desafíos están tomando su lugar.

El contexto, la coordinación y la toma de decisiones se están convirtiendo en los factores que determinan la productividad real de un equipo. Trabajar en múltiples proyectos ya no requiere necesariamente más esfuerzo, pero sí requiere mejores sistemas.

Un stack de trabajo bien diseñado, combinado con un flujo paralelo controlado, permite aprovechar las capacidades de la IA sin caer en el desorden. La meta no es hacer más cosas al mismo tiempo. La meta es construir un entorno donde varias iniciativas puedan avanzar simultáneamente sin perder claridad, calidad ni control.