Arquitectura de software

Cómo estructurar un backend .NET sin sobrearquitectura

Guía práctica con un mismo ejemplo de principio a fin: carpetas por funcionalidad, un caso de uso completo, cuándo abstraer, cómo probarlo y qué señales indican que toca agregar estructura.

Por Leyton Manuel Espitia Diaz 19 min de lectura

Actualizado el

Estructura de carpetas de un backend .NET organizado por funcionalidades

Un desarrollador entra al proyecto y le piden agregar un campo al registro de clientes. Pregunta dónde está el código de clientes. La respuesta: "un poco en cada proyecto". Hay un CustomerController en Api, un CustomerDto en Contracts, un CreateCustomerCommand en Application/Commands, su handler en Application/Handlers, un validador en Application/Validators, la entidad en Domain/Entities y el repositorio en Infrastructure/Repositories.

Siete carpetas para una funcionalidad. Y el proyecto apenas tiene 30 endpoints.

Ya hablamos de si realmente necesitas Clean Architecture. Este artículo es la parte práctica. Para que no se quede en una lista de consejos, todo se desarrolla sobre un mismo ejemplo: un backend de pedidos, desde la primera versión de "cancelar un pedido" hasta el momento en que esa operación ya no cabe en un archivo.

Qué cubre: la estructura de carpetas, la responsabilidad de cada pieza, un caso de uso completo con errores, concurrencia y pruebas, y los criterios para agregar interfaces, proyectos o módulos. Qué deja fuera: autenticación en detalle, despliegue y mensajería; donde aparecen, se enlaza a un artículo que los desarrolla.

El problema: dos formas de equivocarse

Sobrearquitectura es pagar por adelantado una flexibilidad que todavía no se necesita. Se reconoce por síntomas concretos: un cambio pequeño toca cinco o más archivos en proyectos distintos, hay interfaces con una sola implementación que solo reenvían llamadas, y para seguir una petición hay que saltar entre capas que no deciden nada.

El error contrario también existe: un Program.cs de 2.000 líneas, reglas de negocio repetidas en cada endpoint y consultas SQL mezcladas con respuestas HTTP. Eso no es simplicidad, es ausencia de estructura.

Lo que buscamos está en medio: una estructura donde lo que cambia junto vive junto, y donde las fronteras existen en los lugares en los que protegen algo.

La estructura mínima

src/Api/
  Features/
    Customers/
      CreateCustomer.cs      // caso de uso: endpoint + request + lógica
      GetCustomer.cs
      UpdateCustomer.cs
      Customer.cs            // entidad
    Orders/
      PlaceOrder.cs
      CancelOrder.cs
      GetOrders.cs
      Order.cs               // entidad con sus reglas
      OrderLine.cs
      OrderErrors.cs         // errores esperados de la funcionalidad
    Payments/
      ChargeOrder.cs
      IPaymentGateway.cs     // contrato que el negocio necesita
  Infrastructure/
    AppDbContext.cs
    Configurations/          // mapeos de EF Core
    Payments/WompiPaymentGateway.cs
    Email/SmtpEmailSender.cs
  Common/
    Result.cs
    ValidationFilter.cs
  Program.cs
tests/Api.Tests/

La pregunta que más se hace al trabajar en un sistema es "¿dónde está todo lo de X?". Una estructura por tipo técnico (Controllers/, Services/, Repositories/) responde "¿dónde están todos los controladores?", una pregunta que casi nadie se hace.

Organizar por funcionalidad no elimina las responsabilidades: cambia el criterio con el que se agrupan. Dentro de Orders/ siguen existiendo piezas distintas, y conviene tener claro qué es cada una:

Pieza Qué es Dónde vive
Request / Response Contrato HTTP de una operación Dentro del archivo del caso de uso
Entidad (Order) Estado y reglas que valen para todas las operaciones En la carpeta de la funcionalidad
Modelo de lectura Proyección para una consulta concreta En el archivo de la consulta (GetOrders)
Errores (OrderErrors) Resultados esperados con nombre En la carpeta de la funcionalidad
Mapeo de EF Core Cómo se persiste la entidad Infrastructure/Configurations

Dos consecuencias prácticas. La entidad nunca sale por HTTP: cada operación define su propio response, por las razones que explicamos en no expongas tus entidades. Y Customer.cs es una entidad de dominio que EF Core persiste directamente; solo se separa en "modelo de dominio" y "modelo de persistencia" cuando el esquema de la base de datos y el modelo divergen de verdad, algo poco frecuente.

Obligatorio: Features/, un DbContext, un tipo Result. Opcional: Common/ e Infrastructure/ aparecen cuando hay algo que poner en ellas. No se crea por adelantado: proyectos Domain/Application, repositorios, un mediator.

Sobre la frase habitual de que "eliminar una funcionalidad es borrar una carpeta": es la intención, no una garantía. Una funcionalidad también deja rastro en migraciones, permisos, el registro de rutas y, sobre todo, en las referencias desde otras funcionalidades. Lo que la estructura sí garantiza es que localizar el código relacionado sea barato. Que aislarlo sea barato depende de cuidar las dependencias entre carpetas, como veremos más adelante.

Un caso de uso completo: cancelar un pedido

El archivo del caso de uso

namespace Api.Features.Orders;

public static class CancelOrder
{
    public record Request(string Reason);

    public class Validator : AbstractValidator<Request>
    {
        public Validator() => RuleFor(r => r.Reason).NotEmpty().MaximumLength(300);
    }

    public static void Map(IEndpointRouteBuilder app) =>
        app.MapPost("/orders/{id:int}/cancellation", Handle)
           .WithTags("Orders")
           .ProducesProblem(StatusCodes.Status404NotFound)
           .ProducesProblem(StatusCodes.Status409Conflict);

    private static async Task<IResult> Handle(
        int id, Request request, AppDbContext db, TimeProvider clock, CancellationToken ct)
    {
        var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);
        if (order is null) return OrderErrors.NotFound.ToProblem();

        var result = order.Cancel(request.Reason, clock.GetUtcNow());
        if (!result.IsSuccess) return result.Error!.ToProblem();

        try
        {
            await db.SaveChangesAsync(ct);
        }
        catch (DbUpdateConcurrencyException)
        {
            return OrderErrors.ModifiedConcurrently.ToProblem();
        }

        return Results.NoContent();
    }
}

El archivo agrupa el contrato, la validación de forma, la ruta y la coordinación. No contiene la regla de negocio: esa está en la entidad.

Quién decide qué

Este es el reparto que mantiene el archivo corto aunque el sistema crezca:

  • Validador del request: forma de los datos. ¿Viene el motivo? ¿Tiene una longitud razonable? No consulta la base de datos ni conoce el estado del pedido.
  • Entidad: reglas que dependen del estado. ¿Se puede cancelar un pedido despachado? Aplica en cualquier lugar desde el que se cancele: la API, un worker o un proceso de importación.
  • Handler: coordinación. Carga, pide a la entidad que actúe, guarda y traduce el resultado a HTTP.
  • EF Core: persistencia y control de concurrencia.

La regla en la entidad

public class Order
{
    public int Id { get; private set; }
    public int CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public DateTimeOffset? CancelledAt { get; private set; }
    public string? CancellationReason { get; private set; }
    public byte[] RowVersion { get; private set; } = [];

    public Result Cancel(string reason, DateTimeOffset now)
    {
        if (Status == OrderStatus.Cancelled) return OrderErrors.AlreadyCancelled;
        if (Status is OrderStatus.Shipped or OrderStatus.Delivered) return OrderErrors.AlreadyShipped;

        Status = OrderStatus.Cancelled;
        CancelledAt = now;
        CancellationReason = reason;
        return Result.Success();
    }
}

Los setters privados obligan a que cualquier cambio de estado pase por un método con nombre. Esa es la protección real de la regla: no hace falta una capa de servicios para impedir que alguien escriba order.Status = Cancelled desde otro endpoint.

Errores esperados, una sola traducción a HTTP

Que un pedido ya esté despachado no es una excepción: es un resultado previsible, y se modela como tal.

public enum ErrorType { Validation, NotFound, Conflict }

public record Error(string Code, string Message, ErrorType Type);

public static class OrderErrors
{
    public static readonly Error NotFound =
        new("orders.not_found", "El pedido no existe.", ErrorType.NotFound);
    public static readonly Error AlreadyCancelled =
        new("orders.already_cancelled", "El pedido ya fue cancelado.", ErrorType.Conflict);
    public static readonly Error AlreadyShipped =
        new("orders.already_shipped", "El pedido ya fue despachado.", ErrorType.Conflict);
    public static readonly Error ModifiedConcurrently =
        new("orders.modified", "El pedido cambió mientras se procesaba.", ErrorType.Conflict);
}

La entidad no sabe nada de HTTP. La traducción vive en un único lugar de Common/:

public static IResult ToProblem(this Error error) => Results.Problem(
    title: error.Code,
    detail: error.Message,
    statusCode: error.Type switch
    {
        ErrorType.NotFound => StatusCodes.Status404NotFound,
        ErrorType.Conflict => StatusCodes.Status409Conflict,
        _ => StatusCodes.Status400BadRequest
    });

Así todos los endpoints responden con el mismo formato sin repetir la lógica. Las excepciones quedan para lo inesperado (la base de datos no responde, un error de programación) y las atiende el manejador global.

Concurrencia

Dos peticiones llegan a la vez: una cancela el pedido y otra lo marca como despachado. Ambas leen el estado "confirmado", ambas pasan la validación de la entidad y la última en guardar gana. La regla estaba bien escrita y aun así se rompió.

La propiedad RowVersion, configurada con IsRowVersion() en el mapeo, lo resuelve: EF Core incluye la versión en el WHERE del UPDATE y lanza DbUpdateConcurrencyException si la fila cambió. Por eso el handler la captura y responde 409. Es el tipo de detalle que un ejemplo mínimo suele omitir y que en producción aparece el primer día con tráfico real.

Qué se prueba y cómo

La regla se prueba sobre la entidad, sin HTTP, sin base de datos y sin mocks:

[Fact]
public void No_se_puede_cancelar_un_pedido_despachado()
{
    var order = TestOrders.Shipped();

    var result = order.Cancel("El cliente se arrepintió", DateTimeOffset.UtcNow);

    Assert.Equal(OrderErrors.AlreadyShipped, result.Error);
    Assert.Equal(OrderStatus.Shipped, order.Status);
}

El resto se reparte así:

Qué se verifica Tipo de prueba Contra qué
Reglas de la entidad Unitaria Objetos en memoria
Ruta, validación, consulta, concurrencia Integración (WebApplicationFactory) Base de datos real, por ejemplo con Testcontainers
Pasarela de pago, correo Integración con un doble Implementación falsa de la interfaz

"Probar con la implementación real" aplica a la base de datos, porque un proveedor en memoria no reproduce transacciones, restricciones ni rowversion. No significa levantar SQL Server para comprobar un if: la mayoría de las pruebas deberían ser las unitarias de la primera fila, que corren en milisegundos.

Cuando el caso de uso crece

Meses después, cancelar un pedido también debe devolver el inventario reservado y, si el pedido estaba pagado, reembolsar el dinero. El archivo único empieza a mezclar demasiadas cosas. Las señales de que toca dividir son observables:

  • El handler ya no cabe en una pantalla.
  • La misma operación se necesita desde otro punto de entrada (un worker que cancela pedidos impagos).
  • Hay que coordinar varias entidades en una misma transacción.

El siguiente paso no es una capa nueva para todo el sistema, sino una clase para esta operación. El endpoint se queda con HTTP y delega:

public class CancelOrderHandler(AppDbContext db, TimeProvider clock)
{
    public async Task<Result> Handle(int orderId, string reason, CancellationToken ct)
    {
        var order = await db.Orders
            .Include(o => o.Lines)
            .FirstOrDefaultAsync(o => o.Id == orderId, ct);
        if (order is null) return OrderErrors.NotFound;

        var now = clock.GetUtcNow();
        var result = order.Cancel(reason, now);
        if (!result.IsSuccess) return result;

        var productIds = order.Lines.Select(l => l.ProductId).ToList();
        var stock = await db.StockItems
            .Where(s => productIds.Contains(s.ProductId))
            .ToListAsync(ct);

        foreach (var line in order.Lines)
            stock.Single(s => s.ProductId == line.ProductId).Release(line.Quantity);

        if (order.IsPaid)
            db.RefundRequests.Add(RefundRequest.For(order, now));

        try
        {
            await db.SaveChangesAsync(ct);
        }
        catch (DbUpdateConcurrencyException)
        {
            return OrderErrors.ModifiedConcurrently;
        }

        return Result.Success();
    }
}

Tres decisiones que el código no explica por sí solo:

Una transacción, un SaveChangesAsync. El pedido, el inventario y la solicitud de reembolso se guardan juntos o no se guarda nada. Mientras todo esté en el mismo DbContext, no hace falta abrir una transacción explícita. Los casos en que sí hace falta están en errores comunes con transacciones.

La pasarela de pago no se llama aquí. Una llamada HTTP a un tercero no se puede deshacer con un rollback: si el reembolso sale y luego falla el guardado, el dinero ya se devolvió y el pedido sigue activo. Por eso se registra la intención (RefundRequest) en la misma transacción y un proceso en segundo plano ejecuta el reembolso con reintentos. La misma idea sirve para correos y notificaciones.

Secuencia de la cancelación: la API guarda el pedido, el inventario y la solicitud de reembolso en una transacción, y un worker ejecuta el reembolso después

Orders está tocando datos de inventario. En una aplicación pequeña es aceptable y es lo más simple. Pero es exactamente la dependencia que conviene vigilar: si Inventory se convierte en un módulo con sus propias reglas, este acceso directo a StockItems se reemplaza por una llamada a un contrato de ese módulo. Volveremos a esto.

La carpeta queda así, y el resto del sistema no cambió:

Orders/
  CancelOrder/
    CancelOrderEndpoint.cs
    CancelOrderHandler.cs
  PlaceOrder.cs
  GetOrders.cs
  Order.cs

Cuándo una interfaz paga su costo

El criterio corto: dentro del sistema se usan EF Core, TimeProvider y las entidades directamente; las interfaces aparecen donde el sistema habla con algo que no controla. El criterio completo necesita más matices:

Situación ¿Interfaz? Por qué
Pasarela de pago, SMS, correo Sí Cobra, es lenta o tiene efectos reales; hay que simularla en pruebas y el proveedor puede cambiar
Almacenamiento de archivos Sí Disco en local, blob storage en producción: ya hay dos implementaciones
ICustomerRepository que reenvía a DbContext No No protege ninguna regla; DbSet ya es un repositorio
Consulta compleja usada en varios casos de uso Método de extensión o clase concreta Se reutiliza la consulta sin inventar un contrato
Reloj del sistema No hace falta TimeProvider ya es la abstracción
SDK externo que solo se usa en un sitio y es fácil de simular Depende Si la prueba no lo necesita, envolverlo es código de más

Una sola implementación no descarta la interfaz: IPaymentGateway puede tener solo WompiPaymentGateway y aun así justificarse, porque el contrato expresa lo que el negocio necesita (Charge, Refund) y no lo que el SDK ofrece. Lo que la descarta es que no aísle nada. El análisis completo está en por qué abstraer todo puede empeorar un sistema y en Repository Pattern con EF Core.

Usar DbContext directamente tiene un costo que hay que aceptar a conciencia: el sistema queda acoplado a EF Core. Para la mayoría de backends es un buen trato, porque cambiar de ORM es mucho menos probable que cambiar de pasarela de pago.

Lo transversal: resolverlo una vez sin esconder decisiones

"Lo transversal" agrupa cosas que funcionan de forma distinta, y tratarlas igual produce errores:

  • Validación de formato es transversal: un filtro la aplica a todos los endpoints. Validación de negocio no lo es: vive en la entidad.
  • Autenticación (quién eres) es global. Autorización (qué puedes hacer) tiene dos niveles: por rol o política, que se declara en el endpoint, y por recurso, que solo se puede resolver con los datos.
  • Manejo de excepciones es global, pero cubre lo inesperado. Los errores esperados se devuelven de forma explícita con Result.
  • Logging se configura una vez, pero qué registrar se decide en cada caso. Ni contraseñas, ni tokens, ni datos personales completos; más en qué registrar y qué no.

En código, eso se traduce en grupos con alcance distinto en lugar de un único grupo con todo:

var api = app.MapGroup("/api").AddEndpointFilter<ValidationFilter>();

// Rutas sin sesión: cada una se protege a su manera
Login.Map(api);
PaymentWebhook.Map(api);            // valida la firma del proveedor

var secured = api.MapGroup("").RequireAuthorization();
PlaceOrder.Map(secured);
GetOrders.Map(secured);
CancelOrder.Map(secured);

var admin = secured.MapGroup("/admin").RequireAuthorization("Admin");
RefundOrder.Map(admin);

La autorización por recurso no la resuelve ningún filtro. Que un usuario autenticado solo pueda cancelar sus pedidos se expresa en la consulta:

var order = await db.Orders
    .FirstOrDefaultAsync(o => o.Id == id && o.CustomerId == currentUser.CustomerId, ct);

Si el pedido es de otro cliente, la respuesta es 404, no 403: así no se revela que el pedido existe. Es una decisión de cada caso de uso, y por eso se queda a la vista en el caso de uso.

Compartir con cuidado

PlaceOrder y un nuevo CreateQuote (cotizaciones) calculan el total igual: suma de líneas menos descuento más impuesto. La tentación es extraer un PricingService compartido.

Seis meses después, las cotizaciones admiten descuentos negociados por línea y los pedidos deben congelar el precio del momento de la compra. El servicio compartido gana un parámetro isQuote, luego otro, y cada cambio en cotizaciones obliga a volver a probar pedidos. Se parecían, pero no eran la misma regla: tenían motivos distintos para cambiar.

El caso contrario: el cálculo del IVA según el tipo de producto. Es una regla fiscal única; si cambia, cambia para todos a la vez. Ahí compartir es lo correcto, y va en un lugar con nombre propio (TaxCalculator o un método de Product), no en un SharedService genérico.

Antes de extraer, tres preguntas:

  1. Si esta regla cambia para una funcionalidad, ¿debe cambiar para la otra?
  2. ¿Tiene un nombre en el lenguaje del negocio?
  3. ¿Qué cuesta más: mantener dos copias de diez líneas o coordinar cada cambio entre dos funcionalidades?

Duplicar tampoco es gratis: si la regla sí es la misma y hay dos copias, tarde o temprano se corrige un error en una sola. El criterio no es "duplica antes de abstraer", es identificar si hay una regla o dos.

Pocos proyectos, y cómo no arrepentirse

Cada proyecto de la solución es una frontera que el compilador hace cumplir. Eso tiene valor cuando hay algo que proteger o algo que compartir:

Proyecto Cuándo
Api Siempre
Api.Tests Siempre
Worker Cuando hay procesos en segundo plano que se despliegan aparte
Core Cuando la API y el worker comparten entidades y reglas

En el ejemplo, el worker que ejecuta los reembolsos necesita Order y RefundRequest. Ese es el momento de crear Core, no antes.

Hay que ser honestos con el costo: extraer un proyecto no siempre es un cambio mecánico. Mueve namespaces, referencias, el registro de dependencias, el ensamblado de migraciones y las pruebas. Y si las funcionalidades se llamaron libremente entre sí durante dos años, la extracción destapa un acoplamiento que no se arregla moviendo archivos. Lo que mantiene barata esa separación futura son unas pocas reglas que se respetan desde el primer día:

  • Features/ no depende de las implementaciones de Infrastructure/, solo del DbContext y de sus propias interfaces.
  • Una funcionalidad no usa los tipos internos de otra; si necesita algo, lo pide por un punto de entrada explícito.
  • Common/ no conoce ninguna funcionalidad.

Una convención de equipo y una dependencia que el compilador prohíbe no dan la misma garantía. El punto intermedio son las pruebas de arquitectura, que convierten la convención en algo que falla en el pipeline:

[Fact]
public void Las_funcionalidades_no_dependen_de_proveedores_concretos()
{
    var result = Types.InAssembly(typeof(Program).Assembly)
        .That().ResideInNamespace("Api.Features")
        .ShouldNot().HaveDependencyOn("Api.Infrastructure.Payments")
        .GetResult();

    Assert.True(result.IsSuccessful);
}

Con NetArchTest, una regla así cuesta diez líneas y evita la discusión en cada revisión de código.

Cómo crece: el mismo backend en tres etapas

Etapa 1: API pequeña Etapa 2: reglas complejas Etapa 3: módulos
Tamaño típico Hasta ~50 endpoints, un equipo Operaciones que coordinan varias entidades Varios equipos o áreas de negocio
Estructura Un archivo por caso de uso Subcarpetas y handlers donde hace falta Un proyecto o carpeta raíz por módulo, con contrato público
Límites Convención Convención + pruebas de arquitectura Compilador (internal, referencias de proyecto)
Datos Un DbContext Un DbContext Esquema o DbContext por módulo

Lo importante es que el paso de una etapa a otra es local. En el ejemplo, Orders ya está en la etapa 2 con su CancelOrderHandler, mientras Customers sigue en la etapa 1 con tres archivos sencillos, y así debe quedarse. Si una parte del dominio necesita agregados, servicios de dominio o un modelo de lectura separado, los adopta esa parte.

Las señales que justifican subir de etapa se pueden observar, no hay que intuirlas:

  • Un cambio en una funcionalidad rompe con frecuencia pruebas de otra.
  • La misma regla hay que invocarla desde varios puntos de entrada.
  • Una regla no se puede probar sin levantar media aplicación.
  • Dos equipos se bloquean al modificar los mismos archivos.
  • Una parte necesita desplegarse o escalarse con otro ritmo.

Cuando aparecen las dos últimas, el siguiente paso es un monolito modular; el detalle está en arquitectura modular en .NET.

Cuándo esta estructura no es la adecuada

  • Varios equipos desde el inicio: las convenciones no escalan con la cantidad de personas. Conviene empezar con módulos y límites de compilación.
  • Dominio muy complejo en todo el sistema (seguros, contabilidad, logística): un núcleo de dominio aislado se justifica desde el principio.
  • El dominio se comparte entre muchas aplicaciones (API, escritorio, móvil, workers): el proyecto Core es el punto de partida, no una extracción futura.
  • Rotación alta o equipo con poca experiencia: una estructura más rígida, aunque más verbosa, da más protección que una convención que nadie alcanza a aprender.

En todos esos casos el costo de una dependencia incorrecta es mayor que el de la ceremonia adicional. Esa es la comparación que decide, no el gusto por una arquitectura u otra.

Checklist para revisar tu proyecto

  • ¿Un desarrollador nuevo encuentra todo lo de "pedidos" en menos de un minuto?
  • ¿Agregar un campo toca una carpeta, o cinco proyectos?
  • ¿Las reglas de negocio se prueban sin HTTP ni base de datos?
  • ¿Los errores esperados tienen nombre y se traducen a HTTP en un solo lugar?
  • ¿Cada interfaz aísla algo real, o solo reenvía llamadas?
  • ¿Las operaciones que escriben varias entidades lo hacen en una sola transacción, y las llamadas a terceros quedan fuera de ella?
  • ¿Las modificaciones concurrentes están controladas donde importa?
  • ¿Las dependencias entre funcionalidades son pocas, explícitas y están verificadas por una prueba?
  • ¿Cada pieza de estructura adicional responde a una señal que alguien puede señalar?

Conclusión

Para la mayoría de backends, el punto de partida es un proyecto, carpetas por funcionalidad, un archivo por caso de uso, reglas en las entidades e interfaces solo donde el sistema habla con el exterior. No es la ausencia de arquitectura: las responsabilidades siguen separadas, solo que agrupadas por lo que cambia junto.

Se cambia de estructura cuando aparece una señal concreta, y se cambia en la parte que la muestra: un handler cuando una operación coordina varias entidades, un proyecto cuando dos aplicaciones comparten reglas, un módulo cuando dos equipos se estorban. Si hoy no puedes señalar qué problema resuelve una capa, todavía no la necesitas; cuando puedas, agrégala sin culpa.

Leyton Manuel Espitia Diaz

Escrito por

Leyton Manuel Espitia Diaz

CEO y fundador de Nakanaba

Desarrollador de software y fundador de Nakanaba. Lidera la arquitectura de Vitrin: backend en .NET, aplicaciones en Angular y .NET MAUI, SQL Server y servicios en Cloudflare.

¿Tu sistema necesita una revisión de arquitectura?

Te ayudamos a decidir qué estructura necesita tu software hoy y cómo prepararlo para crecer, sin sobreingeniería.

Ver desarrollo a medida