Clean Architecture en .NET 9: Guía Práctica con Ejemplos Reales (Caso Pagly)
Cómo aplicamos Clean Architecture en .NET 9 para construir sistemas que escalan. Capas, dependencias, testing y aprendizajes después de 3 años usándola.
Clean Architecture lleva una década siendo el estándar de facto para sistemas .NET de tamaño medio y grande. No porque sea mágica, sino porque resuelve un problema concreto: cómo mantener un sistema que crece durante años sin convertirse en un monolito imposible de cambiar.
Este artículo no vende Clean Architecture como bala de plata. Cuenta qué es, cómo se aplica con .NET 9, en qué casos tiene sentido y en cuáles no, con ejemplos reales del código que usamos en Pagly (nuestro sistema de planilla para Panamá). También enumera los errores que vimos en proyectos rescatados que decían "usar Clean Architecture" pero en realidad usaban una versión mal entendida.
Qué es Clean Architecture (en serio)
La idea central viene del libro homónimo de Robert C. Martin (2017), que sintetiza patrones más viejos: Hexagonal Architecture, Onion Architecture, DDD. Todos comparten el mismo principio: separar la lógica de negocio del framework, la base de datos y la interfaz de usuario, de modo que esos detalles puedan cambiar sin reescribir el corazón del sistema.
En la práctica significa que su entidad Empleado no sabe que existe una base de datos, ni Entity Framework, ni un controller HTTP. Solo sabe sus reglas: un empleado tiene un salario, no puede tener salario negativo, su antigüedad se calcula desde la fecha de ingreso. Si mañana cambia PostgreSQL por SQL Server, o ASP.NET por gRPC, el código de la entidad no se toca.
Esto suena obvio hasta que ve un proyecto donde EmpleadoController instancia directamente DbContext, hace cálculos de ISR en medio de un método de 400 líneas, y devuelve JSON con strings concatenados. Cambiar cualquier cosa en ese sistema requiere modificar 8 lugares y rezar.
Las 4 capas explicadas
Clean Architecture en .NET se materializa típicamente como una solución con 4 proyectos. La regla absoluta: las dependencias apuntan hacia adentro. Domain no depende de nada. Web depende de todos. Nadie depende de Web.
+------------------------------------------+
| Web (API) |
| Controllers, middleware, DTOs HTTP |
+--------------------+---------------------+
|
+--------------------v---------------------+
| Infrastructure |
| EF Core, repositorios, servicios HTTP, |
| email, storage, integraciones externas |
+--------------------+---------------------+
|
+--------------------v---------------------+
| Application |
| Use cases, CQRS handlers, validators, |
| interfaces que Infrastructure implementa|
+--------------------+---------------------+
|
+--------------------v---------------------+
| Domain |
| Entidades, value objects, eventos, |
| excepciones de dominio, reglas puras |
+------------------------------------------+
Domain — el corazón inmutable
Solo C# puro. Sin referencias a NuGets, sin atributos de EF, sin DTOs. Aquí viven las entidades, los value objects (Money, Email, RUC), los eventos de dominio y las excepciones de negocio.
Application — los casos de uso
Define qué puede hacer el sistema. Cada caso de uso es típicamente un Command o Query (CQRS) con su Handler. También define las interfaces que Infrastructure implementará (IEmpleadoRepository, IEmailSender). Application no sabe cómo se persiste ni cómo se envían correos, solo que estos contratos existen.
Infrastructure — los detalles cambiables
Implementa las interfaces de Application. Aquí viven EmpleadoRepository (con EF Core), SendGridEmailSender, AzureBlobStorage. Si cambia la tecnología, solo se toca Infrastructure.
Web — el adaptador HTTP
Controllers o Minimal APIs que reciben requests, los traducen a Commands/Queries, ejecutan handlers (vía MediatR o un dispatcher propio), y devuelven respuestas. No tiene lógica de negocio. Su trabajo es traducir HTTP ↔ Application.
Cuándo SÍ usarla y cuándo NO
Sí tiene sentido cuando:
- El sistema va a crecer y vivir más de 2 años
- Tiene reglas de negocio reales (no es un CRUD trivial)
- El equipo es de 2+ desarrolladores
- Necesita testing serio (unit + integration)
- Va a integrar múltiples sistemas externos
- La probabilidad de cambiar de tecnología (DB, ORM, framework) en el futuro es alta
NO tiene sentido cuando:
- Es un MVP que validará una hipótesis en 2 meses (use Minimal APIs + EF directo)
- Es un proyecto desechable / proof of concept
- Es un microservicio de 200 líneas con una sola responsabilidad
- El equipo es de 1 persona que nunca trabajará con otros en este código
- El dominio es trivial (CRUD puro de 4 entidades, sin reglas)
Aplicar Clean Architecture a un sistema simple es como construir una casa con cimientos para 5 pisos cuando solo va a tener 1. Funciona, pero pagó de más por algo que no necesitaba.
Ejemplo de código: cálculo de ISR en Pagly
Vamos con un ejemplo real. Pagly calcula el ISR mensual de un empleado en Panamá según los tramos del Decreto Ejecutivo No. 170. Veamos cómo se distribuye en las 4 capas.
Domain: la entidad y la regla pura
namespace Pagly.Domain.Empleados;
public sealed class Empleado
{
public Guid Id { get; private set; }
public string Nombre { get; private set; }
public Money SalarioMensual { get; private set; }
public DateOnly FechaIngreso { get; private set; }
private Empleado() { } // EF Core
public static Empleado Crear(string nombre, decimal salario, DateOnly fechaIngreso)
{
if (string.IsNullOrWhiteSpace(nombre))
throw new DomainException("El nombre del empleado es obligatorio.");
if (salario <= 0)
throw new DomainException("El salario debe ser positivo.");
return new Empleado
{
Id = Guid.CreateVersion7(),
Nombre = nombre.Trim(),
SalarioMensual = Money.De(salario, Moneda.PAB),
FechaIngreso = fechaIngreso,
};
}
public Money CalcularSalarioAnualBruto() => SalarioMensual * 13; // 12 + decimo
}
namespace Pagly.Domain.Calculos;
// Service de dominio: regla de negocio pura, sin dependencias externas
public static class CalculadoraIsrPanama
{
private static readonly IsrTramo[] Tramos =
[
new( Hasta: 11_000m, Tasa: 0.00m, Fijo: 0m),
new( Hasta: 50_000m, Tasa: 0.15m, Fijo: 0m),
new( Hasta: decimal.MaxValue, Tasa: 0.25m, Fijo: 5_850m),
];
public static Money IsrAnual(Money salarioAnual)
{
var monto = salarioAnual.Valor;
var aplicado = 0m;
var pisoTramo = 0m;
foreach (var tramo in Tramos)
{
if (monto <= tramo.Hasta)
{
aplicado = tramo.Fijo + (monto - pisoTramo) * tramo.Tasa;
break;
}
pisoTramo = tramo.Hasta;
}
return Money.De(Math.Round(aplicado, 2), Moneda.PAB);
}
public static Money IsrMensual(Money salarioAnual)
=> IsrAnual(salarioAnual) / 12;
private record IsrTramo(decimal Hasta, decimal Tasa, decimal Fijo);
}
Esto es Domain puro. Se prueba con xUnit en milisegundos, sin levantar base de datos ni ASP.NET.
Application: el caso de uso
namespace Pagly.Application.Empleados.CalcularIsr;
public sealed record CalcularIsrEmpleadoQuery(Guid EmpleadoId)
: IRequest<Result<CalculoIsrDto>>;
public sealed record CalculoIsrDto(
Guid EmpleadoId,
string Nombre,
decimal SalarioAnualBruto,
decimal IsrAnual,
decimal IsrMensual);
public sealed class CalcularIsrEmpleadoHandler
: IRequestHandler<CalcularIsrEmpleadoQuery, Result<CalculoIsrDto>>
{
private readonly IEmpleadoRepository _empleados;
public CalcularIsrEmpleadoHandler(IEmpleadoRepository empleados)
{
_empleados = empleados;
}
public async Task<Result<CalculoIsrDto>> Handle(
CalcularIsrEmpleadoQuery query,
CancellationToken ct)
{
var empleado = await _empleados.ObtenerPorIdAsync(query.EmpleadoId, ct);
if (empleado is null)
return Result.Fail<CalculoIsrDto>("Empleado no encontrado.");
var anualBruto = empleado.CalcularSalarioAnualBruto();
var isrAnual = CalculadoraIsrPanama.IsrAnual(anualBruto);
var isrMensual = CalculadoraIsrPanama.IsrMensual(anualBruto);
return Result.Ok(new CalculoIsrDto(
empleado.Id,
empleado.Nombre,
anualBruto.Valor,
isrAnual.Valor,
isrMensual.Valor));
}
}
Application define IEmpleadoRepository (interfaz) que Infrastructure implementará. Application no sabe si los datos vienen de PostgreSQL, SQL Server o un mock en memoria.
Infrastructure: la persistencia real
namespace Pagly.Infrastructure.Empleados;
public sealed class EmpleadoRepository : IEmpleadoRepository
{
private readonly PaglyDbContext _db;
public EmpleadoRepository(PaglyDbContext db) => _db = db;
public Task<Empleado?> ObtenerPorIdAsync(Guid id, CancellationToken ct)
=> _db.Empleados
.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == id, ct);
}
Aquí sí aparece EF Core. Pero está aislado: si mañana se cambia a Dapper, solo se toca este archivo y el DbContext.
Web: el endpoint Minimal API (.NET 9)
namespace Pagly.Web.Endpoints;
public static class EmpleadoEndpoints
{
public static void MapEmpleadoEndpoints(this WebApplication app)
{
var grupo = app.MapGroup("/api/empleados").WithTags("Empleados");
grupo.MapGet("/{id:guid}/isr", async (
Guid id,
ISender sender,
CancellationToken ct) =>
{
var resultado = await sender.Send(new CalcularIsrEmpleadoQuery(id), ct);
return resultado.IsSuccess
? Results.Ok(resultado.Value)
: Results.NotFound(resultado.Error);
})
.WithName("CalcularIsrEmpleado")
.Produces<CalculoIsrDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
}
}
El controller (Minimal API en este caso) no calcula nada. Despacha el query, devuelve el resultado. Si la regla de ISR cambia, se modifica CalculadoraIsrPanama en Domain y todo lo demás sigue igual.
Testing por capa
Una de las mayores ventajas de esta arquitectura es que cada capa se prueba con la herramienta correcta.
Domain: unit tests puros (xUnit)
public class CalculadoraIsrPanamaTests
{
[Theory]
[InlineData(10_000, 0)] // Bajo el minimo exento
[InlineData(11_000, 0)] // Justo en el limite
[InlineData(20_000, 1_350)] // (20000-11000) * 0.15
[InlineData(50_000, 5_850)] // (50000-11000) * 0.15
[InlineData(75_000, 12_100)] // 5850 + (75000-50000)*0.25
public void IsrAnual_DebeCoincidirConTramosOficiales(
decimal salarioAnual, decimal isrEsperado)
{
var resultado = CalculadoraIsrPanama.IsrAnual(
Money.De(salarioAnual, Moneda.PAB));
resultado.Valor.Should().Be(isrEsperado);
}
}
Estos tests corren en milisegundos. Sin base de datos, sin red, sin nada.
Application: unit tests con mocks (NSubstitute)
public class CalcularIsrEmpleadoHandlerTests
{
[Fact]
public async Task Handle_RetornaCalculoCuandoEmpleadoExiste()
{
var empleado = Empleado.Crear("Juan Perez", 2_500m, new DateOnly(2020, 1, 15));
var repo = Substitute.For<IEmpleadoRepository>();
repo.ObtenerPorIdAsync(empleado.Id, Arg.Any<CancellationToken>())
.Returns(empleado);
var handler = new CalcularIsrEmpleadoHandler(repo);
var resultado = await handler.Handle(
new CalcularIsrEmpleadoQuery(empleado.Id),
CancellationToken.None);
resultado.IsSuccess.Should().BeTrue();
resultado.Value.SalarioAnualBruto.Should().Be(32_500m); // 2500 * 13
}
}
Infrastructure: integration tests (Testcontainers + PostgreSQL real)
public class EmpleadoRepositoryTests : IAsyncLifetime
{
private PostgreSqlContainer _postgres = null!;
private PaglyDbContext _db = null!;
public async Task InitializeAsync()
{
_postgres = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
await _postgres.StartAsync();
var options = new DbContextOptionsBuilder<PaglyDbContext>()
.UseNpgsql(_postgres.GetConnectionString())
.Options;
_db = new PaglyDbContext(options);
await _db.Database.MigrateAsync();
}
[Fact]
public async Task ObtenerPorId_RetornaEmpleadoPersistido()
{
var empleado = Empleado.Crear("Maria Lopez", 3_200m, new DateOnly(2021, 6, 1));
_db.Empleados.Add(empleado);
await _db.SaveChangesAsync();
var repo = new EmpleadoRepository(_db);
var result = await repo.ObtenerPorIdAsync(empleado.Id, default);
result.Should().NotBeNull();
result!.Nombre.Should().Be("Maria Lopez");
}
public Task DisposeAsync() => _postgres.DisposeAsync().AsTask();
}
Web: tests E2E con WebApplicationFactory
public class EmpleadoEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public EmpleadoEndpointsTests(WebApplicationFactory<Program> factory)
=> _client = factory.CreateClient();
[Fact]
public async Task GET_isr_retorna404_cuandoEmpleadoNoExiste()
{
var response = await _client.GetAsync($"/api/empleados/{Guid.NewGuid()}/isr");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
| Capa | Tipo de test | Velocidad | Cobertura objetivo | |---|---|---|---| | Domain | Unit | < 10ms cada uno | 95%+ | | Application | Unit con mocks | < 50ms cada uno | 80%+ | | Infrastructure | Integration con DB | 1–3s cada uno | 60%+ | | Web | E2E | 2–5s cada uno | flujos críticos |
5 errores comunes en proyectos "Clean Architecture"
Hemos rescatado proyectos que decían usar Clean Architecture pero violaban sus principios fundamentales. Los errores más frecuentes:
1. Anemic Domain Model
Las entidades son solo bolsas de propiedades públicas con setters. Toda la lógica vive en "services" del Application layer. Resultado: Application crece sin control y Domain queda vacío.
Mal:
public class Empleado
{
public Guid Id { get; set; }
public decimal Salario { get; set; }
public DateOnly FechaIngreso { get; set; }
}
// Logica regada en EmpleadoService, EmpleadoValidator, EmpleadoCalculator...
Bien: entidades con métodos que encapsulan reglas (Empleado.Crear(), empleado.AumentarSalario(...), empleado.Despedir(...)).
2. Domain dependiendo de NuGets externos
Atributos de EF Core ([Table], [Required]) en las entidades. Atributos de validación de FluentValidation. Esto ata Domain a librerías que pueden cambiar. Domain solo debe depender del BCL de .NET.
Solución: configurar EF con Fluent API en Infrastructure (IEntityTypeConfiguration<Empleado>), validar con FluentValidation en Application.
3. Repositorios como wrappers triviales de DbContext
public class EmpleadoRepository : IEmpleadoRepository
{
public IQueryable<Empleado> Query() => _db.Empleados; // expuso EF
public void Add(Empleado e) => _db.Add(e);
public Task SaveAsync() => _db.SaveChangesAsync();
}
Si el repositorio expone IQueryable, Application puede armar queries de EF, lo que rompe la independencia. El repositorio debe exponer métodos con intención (ObtenerActivosPorDepartamentoAsync), no un IQueryable genérico.
4. Inyectar IConfiguration en cualquier capa
Configuración (connection strings, API keys, feature flags) solo debe leerse en Web (Program.cs) y pasarse vía Options pattern (IOptions<T>). Si Application depende de IConfiguration, ya no es portable.
5. CQRS sin razón
Aplicar MediatR + Commands + Queries en un CRUD trivial no agrega valor, solo agrega ceremonia. CQRS tiene sentido cuando: hay caminos de lectura y escritura con modelos distintos, hay event sourcing, o hay handlers complejos que se benefician de pipelines (validación, logging, transacciones). Para un endpoint que devuelve una lista, un controller que llama a un service simple es perfectamente válido.
Cómo organizamos esto en Pagly
La solución de Pagly tiene esta estructura:
Pagly/
├── src/
│ ├── Pagly.Domain/
│ │ ├── Empleados/
│ │ ├── Planillas/
│ │ ├── Calculos/ (servicios de dominio: ISR, CSS, decimo)
│ │ ├── Common/ (Money, Result, DomainException)
│ │ └── Pagly.Domain.csproj
│ │
│ ├── Pagly.Application/
│ │ ├── Empleados/
│ │ │ ├── Crear/
│ │ │ ├── ListarActivos/
│ │ │ └── CalcularIsr/
│ │ ├── Planillas/
│ │ ├── Abstractions/ (interfaces: IEmpleadoRepository, IUnitOfWork)
│ │ ├── Behaviors/ (pipelines MediatR: validation, logging, transaction)
│ │ └── Pagly.Application.csproj
│ │
│ ├── Pagly.Infrastructure/
│ │ ├── Persistence/ (PaglyDbContext, configurations, migrations)
│ │ ├── Repositories/
│ │ ├── Services/ (Email, Storage, integraciones externas)
│ │ └── Pagly.Infrastructure.csproj
│ │
│ └── Pagly.Web/
│ ├── Endpoints/ (Minimal APIs)
│ ├── Middleware/
│ ├── Program.cs
│ └── Pagly.Web.csproj
│
└── tests/
├── Pagly.Domain.UnitTests/
├── Pagly.Application.UnitTests/
├── Pagly.Infrastructure.IntegrationTests/
└── Pagly.Web.E2ETests/
Algunas decisiones específicas:
Organización por feature, no por tipo. Dentro de Application no separamos Commands/, Queries/, Validators/ en carpetas distintas. Cada feature (ej: CrearEmpleado/) contiene su Command, Handler, Validator y DTOs en una sola carpeta. Esto reduce el "shotgun surgery" cuando un feature cambia.
MediatR con pipeline behaviors. Validación con FluentValidation, logging y manejo de transacciones se aplican como behaviors transparentes. Cada handler ya recibe datos validados y dentro de una transacción gestionada.
Result pattern en lugar de excepciones para flujo. Las excepciones se reservan para errores genuinos. Para validaciones de negocio (empleado no encontrado, salario inválido) usamos Result<T> con IsSuccess / Error.
Migrations en Infrastructure, no en Web. Las migrations de EF se aplican con un job dedicado al despliegue, no automáticamente al iniciar la API. Esto evita que dos pods de Kubernetes intenten migrar la base de datos al mismo tiempo.
Recursos para profundizar
- Libro: Clean Architecture (Robert C. Martin, 2017) — la fuente original
- Libro: Implementing Domain-Driven Design (Vaughn Vernon) — para entender el modelado del dominio que vive en la capa Domain
- Repo: Clean Architecture Solution Template de Jason Taylor — referencia .NET ampliamente adoptada
- Repo: Modular Monolith with DDD de Kamil Grzybek — para sistemas más grandes
- Curso: "Vertical Slice Architecture" de Jimmy Bogard — alternativa interesante para sistemas con muchos features pequeños
- Documentación oficial: Microsoft Learn — .NET application architecture
¿Construyendo un sistema .NET serio?
En Vorluno construimos sistemas en .NET 9 con Clean Architecture desde hace años. Pagly (planilla Panamá), Core360 (ERP) y proyectos a medida para clientes están todos sobre esta arquitectura.
Si está iniciando un proyecto .NET y no quiere terminar con un monolito imposible de mantener en 2 años, conversemos. Hacemos auditorías de arquitectura para proyectos existentes y scaffolding desde cero para nuevos. Sin recetarle Clean Architecture si su proyecto no la necesita.
Escrito por