Clean Architecture es una forma de estructurar una aplicación C# para que las reglas de negocio estén en el centro y no dependan de nada. La base de datos, el framework web y cualquier otro detalle dependen de ellas, nunca al revés.
En .NET, eso suele significar cuatro proyectos: Domain, Application, Infrastructure y Api.
Empezaremos por la teoría. Después construiremos una pequeña API de reserva de entradas proyecto a proyecto, la ejecutaremos, la probaremos y haremos que el compilador vigile las reglas por nosotros.
Profundizamos mucho más en nuestro curso de Clean Architecture: construyes un sistema completo de venta de entradas en .NET 10, desde los eventos de dominio hasta las pruebas de integración.
¿Qué es Clean Architecture en C#?
Clean Architecture organiza el código según lo cerca que está del negocio. Robert C. Martin la describió en una entrada de blog de 2012 como un conjunto de círculos, con las reglas de negocio en el centro y los detalles técnicos en el exterior.
Una sola regla lo mantiene todo unido. La regla de dependencia (Dependency Rule) dice que las dependencias del código fuente apuntan hacia dentro, hacia las reglas de negocio, de modo que el código interior nunca menciona una base de datos, un framework web ni ningún otro detalle exterior.
En una solución C#, los círculos suelen convertirse en cuatro proyectos. Domain contiene las entidades y las reglas de negocio, y no hace referencia a nada. Application contiene los casos de uso y las interfaces que estos necesitan del mundo exterior.
Infrastructure implementa esas interfaces con EF Core, archivos o servicios externos. Api recibe las solicitudes HTTP y lo conecta todo al arrancar.
Esa estructura compensa de dos maneras. Las reglas que más te importan se ejecutan en pruebas unitarias sin base de datos, y un cambio técnico se queda dentro de un único proyecto exterior.
Martin enuncia la regla en una sola frase en su entrada The Clean Architecture: “Esta regla dice que las dependencias del código fuente solo pueden apuntar hacia dentro”.
Este es el diagrama clásico, redibujado:

Los nombres de los anillos vienen de la entrada original y no se corresponden uno a uno con proyectos .NET. Entities se convierte en nuestro proyecto Domain, y Use Cases, en Application.
Los dos anillos exteriores, Interface Adapters y Frameworks and Drivers, acaban repartidos entre Infrastructure y Api. Esos dos proyectos quedan uno junto al otro en el exterior.
¿Qué problema resuelve Clean Architecture?
Empecemos por un código que tiene el problema. Este endpoint de API mínima reserva entradas para un evento.
Funciona y es corto, pero no lo copies, porque es el diseño que estamos a punto de desmontar:
app.MapPost("/api/events/{eventId:int}/reservations",
async (int eventId, ReserveRequest request, TicketingDbContext db) =>
{
var ev = await db.Events.FindAsync(eventId);
if (ev is null)
return Results.NotFound();
if (ev.TicketsSold + request.Quantity > ev.Capacity)
return Results.Conflict("Sold out");
ev.TicketsSold += request.Quantity;
await db.SaveChangesAsync();
return Results.Ok(ev);
});
El Event con el que trabaja es un simple contenedor de datos, y es parte del problema:
public class Event
{
public int Id { get; set; }
public string Name { get; set; } = "";
public int Capacity { get; set; }
public int TicketsSold { get; set; }
}
Aquí, una sola lambda lo hace todo. Carga el evento con EF Core y comprueba la regla de capacidad.
Después modifica la entidad, la guarda y devuelve la entidad de base de datos como respuesta HTTP.
Entonces, ¿qué tiene de malo? Nada, de momento. Los problemas empiezan cuando la aplicación crece:
- No puedes probar la regla contra la sobreventa sin una base de datos, porque vive dentro de un handler HTTP que necesita un
TicketingDbContext. - La regla no tiene dueño.
TicketsSoldtiene un setter público, así que el endpoint de reembolsos que escribas el mes que viene puede cambiarlo sin comprobar nada. - La tabla de la base de datos es el contrato de la API.
Results.Ok(ev)serializa la entidad, así que, cuando cambias el nombre de una columna, todos los clientes ven el cambio. - Las decisiones técnicas se filtran al código de negocio. Si pasas el acceso a datos de EF Core a Dapper, tienes que reescribir esta lambda, regla incluida.
Los cuatro problemas tienen una única causa. La regla de negocio, la única línea que nos impide vender de más, depende de los detalles que la rodean: EF Core, SQLite, HTTP y JSON.
Clean Architecture le da la vuelta a esa flecha, de modo que los detalles dependen de la regla:

A menudo oirás que Clean Architecture te permite cambiar de base de datos. Pocos equipos llegan a hacerlo, así que es un motivo débil para adoptarla.
En cambio, hay dos beneficios que se notan cada semana. Tus reglas más importantes se ejecutan en pruebas que tardan milisegundos, y un cambio técnico se queda dentro de un solo proyecto.
¿Qué es la regla de dependencia?
La regla de dependencia dice que las dependencias del código fuente apuntan hacia dentro. Una clase de un proyecto interior no puede usar, ni siquiera nombrar, una clase de un proyecto exterior.
Entonces, si los casos de uso no pueden hacer referencia al código de base de datos, ¿cómo carga un caso de uso algo de la base de datos?
A través de una interfaz que pertenece al proyecto interior. El proyecto Application declara lo que necesita, por ejemplo:
public interface IEventRepository
{
Task<Event?> GetByIdAsync(int eventId, CancellationToken cancellationToken = default);
}
Infrastructure implementa esta interfaz con EF Core, y el contenedor de inyección de dependencias (DI) entrega esa implementación al caso de uso en tiempo de ejecución.
Así que, en tiempo de ejecución, la llamada viaja hacia fuera, del caso de uso a la base de datos. La referencia en tiempo de compilación apunta hacia dentro, de Infrastructure a la interfaz de Application.
Las flechas que apuntan hacia dentro en el diagrama de Clean Architecture solo muestran el segundo tipo, las dependencias que comprueba el compilador. Si las lees como llamadas en tiempo de ejecución, todo el diagrama parece estar al revés.
Una interfaz que pertenece a una capa interior se llama puerto. La clase exterior que la implementa es un adaptador.
Si esto te suena al principio de inversión de dependencias, tienes razón. La regla de dependencia aplica ese principio a proyectos enteros.
¿Qué vamos a construir?
Una característica de un sistema de venta de entradas: reservar entradas para un evento. Tiene una sola regla de negocio, la que le importa a cualquier taquilla: nunca vendemos más entradas de las que tiene un evento.
La API tendrá dos endpoints:
POST /api/events/{eventId}/reservationsreserva entradas.GET /api/events/{eventId}informa de cuántas entradas quedan.
Es lo bastante pequeño como para seguirlo de una sentada, y lo bastante grande como para mostrar cada capa haciendo su trabajo. Construimos de dentro hacia fuera: primero Domain, luego Application, Infrastructure y la Api.
Paso 1: crear la solución y los cuatro proyectos
Necesitarás el SDK de .NET 10. Los comandos de abajo usan sus formas con el sustantivo delante, como dotnet solution add y dotnet reference add.
Los SDK anteriores a la versión 9.0.300 no las tienen todas y usan dotnet sln add y dotnet add reference en su lugar.
Creemos la solución y un proyecto por capa:
dotnet new sln -n EventTicketing dotnet new classlib -n EventTicketing.Domain dotnet new classlib -n EventTicketing.Application dotnet new classlib -n EventTicketing.Infrastructure dotnet new web -n EventTicketing.Api dotnet solution add EventTicketing.Domain EventTicketing.Application EventTicketing.Infrastructure EventTicketing.Api
Cada proyecto tiene un único trabajo:
EventTicketing.Domaincontiene el negocio: la entidad, su regla y los errores que esa regla puede producir.EventTicketing.Applicationcontiene los casos de uso, una clase por cada cosa que hace la aplicación.EventTicketing.Infrastructurese comunica con el mundo exterior, que en nuestro caso es una base de datos SQLite a través de EF Core.EventTicketing.Apies la frontera web. Recibe las solicitudes HTTP y conecta entre sí los demás proyectos.
La plantilla de biblioteca de clases añade un Class1.cs a cada biblioteca. No hacen falta, así que borra los tres. Con el SDK de .NET 10, dotnet new sln crea EventTicketing.slnx, el nuevo formato XML de solución.
A continuación vienen las referencias entre proyectos. Estos tres comandos son la regla de dependencia, escrita una sola vez:
dotnet reference add EventTicketing.Domain --project EventTicketing.Application dotnet reference add EventTicketing.Application --project EventTicketing.Infrastructure dotnet reference add EventTicketing.Application EventTicketing.Infrastructure --project EventTicketing.Api
Application hace referencia a Domain. Infrastructure hace referencia a Application y, a través de ella, a Domain. La Api hace referencia a Application e Infrastructure, y Domain no hace referencia a nada en absoluto.

¿Por qué la Api hace referencia a Infrastructure? Para que Program.cs pueda registrar las clases de EF Core que hay detrás de las interfaces de Application.
Program.cs es la raíz de composición (composition root), el único lugar donde se juntan los servicios de todas las capas. Ningún endpoint usa un tipo de Infrastructure.
Por último, cada proyecto recibe los paquetes que necesita, y nada más:
dotnet package add Microsoft.Extensions.DependencyInjection.Abstractions --version 10.0.12 --project EventTicketing.Application dotnet package add Microsoft.EntityFrameworkCore.Sqlite --version 10.0.12 --project EventTicketing.Infrastructure
Application recibe solo las abstracciones de DI, para poder registrar sus propios servicios. EF Core va a Infrastructure y a ningún otro sitio. Fijamos las versiones para que los comandos sigan funcionando cuando salga .NET 11.
Paso 2: construir la capa de dominio
El proyecto Domain contiene el negocio. No hace referencia a ningún otro proyecto ni a ningún paquete, así que su código no puede llegar a EF Core ni a ASP.NET Core.
Nuestra regla vive en una entidad Event, y Event necesita una cosa antes de que la escribamos: una forma de decir que no.
¿Cómo maneja los fallos el patrón Result?
Agotar las entradas no es un fallo del programa. Los clientes intentan comprar las últimas plazas todos los días, y en cada una de esas solicitudes el sistema funciona correctamente.
Así que Event no debería lanzar una excepción cuando se agotan las entradas. Debería devolver un resultado que diga qué salió mal. Clasificamos cada fallo con una sola pregunta:

Si un usuario que se comporta correctamente puede provocarlo un martes cualquiera, se trata de un fallo esperado, y lo devolvemos como un valor.
En caso contrario, algo está roto, y lanzamos una excepción. Que se agoten las entradas o que el ID del evento no exista son fallos esperados. Una cantidad negativa que se ha colado a través de la validación, o una caída de la base de datos, no lo son.
Crea EventTicketing.Domain/Result.cs:
namespace EventTicketing.Domain;
public enum ErrorType
{
NotFound,
Conflict
}
public sealed record Error(string Code, string Description, ErrorType Type);
public class Result
{
protected Result(Error? error) => Error = error;
public Error? Error { get; }
public bool IsSuccess => Error is null;
public static Result Success() => new(null);
public static implicit operator Result(Error error) => new(error);
}
public sealed class Result<TValue> : Result
{
private readonly TValue? _value;
private Result(TValue? value, Error? error)
: base(error) => _value = value;
public TValue Value => IsSuccess
? _value!
: throw new InvalidOperationException("A failed result has no value.");
public static implicit operator Result<TValue>(TValue value) =>
new(value, null);
public static implicit operator Result<TValue>(Error error) =>
new(default, error);
}
Aquí, Error describe un fallo con un código, un mensaje y un tipo. Más adelante, ErrorType le indica a la frontera de la API qué estado HTTP enviar, y Domain nunca llega a saber que HTTP existe.
Result es o bien un éxito o bien un fallo que lleva un Error.
Result<TValue> añade un valor para las operaciones que devuelven uno. Value lanza una excepción si lo lees de un resultado fallido, así que tienes que comprobar IsSuccess primero.
Los operadores implícitos permiten que un método devuelva con return un valor normal o un Error, y el compilador lo envuelve en el Result adecuado. Eso es lo que mantiene cortos nuestros casos de uso.
¿Por qué escribir el nuestro? Tiene menos de 40 líneas y controlamos cada una de ellas. Si prefieres usar una biblioteca, ErrorOr, FluentResults y Ardalis.Result resuelven el mismo problema.
Para saber más sobre el patrón en sí, consulta nuestro artículo sobre el patrón Result en .NET Web API.
La entidad Event y su regla
Los errores viven junto a la entidad que describen. Crea EventTicketing.Domain/EventErrors.cs:
namespace EventTicketing.Domain;
public static class EventErrors
{
public static Error NotFound(int eventId) =>
new("Event.NotFound", $"Event {eventId} was not found.", ErrorType.NotFound);
public static Error SoldOut(int ticketsLeft, int requested) =>
new("Event.SoldOut", $"Only {ticketsLeft} tickets left, {requested} requested.", ErrorType.Conflict);
}
Cada error tiene un código estable, como Event.SoldOut, y un mensaje para las personas. El código de tus clientes debería comprobar el código de error, porque puede que la redacción del mensaje cambie más adelante.
Ahora, la entidad en sí. Crea EventTicketing.Domain/Event.cs:
namespace EventTicketing.Domain;
public sealed class Event
{
private Event(string name, int capacity)
{
Name = name;
Capacity = capacity;
}
public int Id { get; private set; }
public string Name { get; private set; }
public int Capacity { get; private set; }
public int TicketsSold { get; private set; }
public int TicketsLeft => Capacity - TicketsSold;
public static Event Create(string name, int capacity)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
return new Event(name, capacity);
}
public Result Reserve(int quantity)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
if (quantity > TicketsLeft)
return EventErrors.SoldOut(TicketsLeft, quantity);
TicketsSold += quantity;
return Result.Success();
}
}
Aquí, todos los setters son privados, así que el código de fuera de la clase no puede cambiar los números. Create() es la única forma de crear un evento, y Reserve() es la única forma de vender entradas.
Reserve() tiene dos maneras de decir que no. Una cantidad de cero o menos es un error del llamador, así que ThrowIfNegativeOrZero() lanza una excepción.
Un evento agotado es un día normal en la taquilla, así que el método devuelve EventErrors.SoldOut en su lugar. TicketsSold solo cambia cuando la regla se cumple.
Event es pequeño a propósito. Un dominio más grande tendría también objetos de valor, como un tipo Money que rechaza importes negativos.
También tendría agregados, grupos de objetos que cambian juntos detrás de un único punto de entrada. Nuestro artículo sobre el diseño de agregados los explica en .NET.
Paso 3: escribir los casos de uso en la capa de aplicación
El proyecto Application contiene los casos de uso. Cada caso de uso es una clase. Un caso de uso que modifica datos carga lo que necesita, le pide a Domain que haga el trabajo y guarda el resultado.
Un caso de uso no puede hablar directamente con la base de datos, porque Application no puede hacer referencia a Infrastructure. Así que declara lo que necesita como un puerto. Crea EventTicketing.Application/IEventRepository.cs:
using EventTicketing.Domain;
namespace EventTicketing.Application;
public interface IEventRepository
{
Task<Event?> GetByIdAsync(int eventId, CancellationToken cancellationToken = default);
Task<EventAvailability?> GetAvailabilityAsync(int eventId, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
GetByIdAsync() carga un evento que vamos a modificar. GetAvailabilityAsync() lee lo que muestra el endpoint de disponibilidad, como un record EventAvailability que escribiremos en un momento. SaveChangesAsync() confirma los cambios. Infrastructure implementa los tres en el paso 4.
¿Dónde deberían vivir las interfaces de repositorio? En algunas plantillas las encontrarás en Domain y en otras, en Application.
Nosotros las dejamos en Application, junto a los casos de uso que las llaman, porque el código de Domain nunca carga ni guarda nada.
Si tus servicios de dominio sí necesitan consultar datos, Domain también es un buen sitio. Elige un lugar y sé coherente.
Ahora, el primer caso de uso. Un comando es un record que transporta la entrada, y un handler hace el trabajo. Crea EventTicketing.Application/ReserveTickets.cs:
using EventTicketing.Domain;
namespace EventTicketing.Application;
public sealed record ReserveTicketsCommand(int EventId, int Quantity);
public sealed record ReservationResponse(int EventId, int TicketsReserved, int TicketsLeft);
public sealed class ReserveTicketsHandler(IEventRepository events)
{
public async Task<Result<ReservationResponse>> HandleAsync(
ReserveTicketsCommand command, CancellationToken cancellationToken = default)
{
var ev = await events.GetByIdAsync(command.EventId, cancellationToken);
if (ev is null)
return EventErrors.NotFound(command.EventId);
var reservation = ev.Reserve(command.Quantity);
if (!reservation.IsSuccess)
return reservation.Error!;
await events.SaveChangesAsync(cancellationToken);
return new ReservationResponse(ev.Id, command.Quantity, ev.TicketsLeft);
}
}
Aquí, el handler carga el evento a través del puerto y devuelve el error Event.NotFound cuando ese evento no existe.
Después, le pide a la entidad que reserve las entradas y, si la entidad dice que no, el handler transmite ese error sin cambios.
Solo si todo va bien llama a SaveChangesAsync(). Por último, construye un ReservationResponse a mano, con una única expresión new que el compilador comprueba.
El handler nunca comprueba la capacidad por sí mismo. Event es el dueño de esa regla, y el handler solo ordena los pasos: cargar, reservar, guardar y responder.

Ebook gratis
¿Tu Web API está lista para producción?
33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.
El ebook está en inglés.
Descarga la checklist gratisPDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.
El segundo caso de uso responde a una pregunta y no cambia nada. Crea EventTicketing.Application/GetEventAvailability.cs:
using EventTicketing.Domain;
namespace EventTicketing.Application;
public sealed record GetEventAvailabilityQuery(int EventId);
public sealed record EventAvailability(int EventId, string Name, int TicketsLeft);
public sealed class GetEventAvailabilityHandler(IEventRepository events)
{
public async Task<Result<EventAvailability>> HandleAsync(
GetEventAvailabilityQuery query, CancellationToken cancellationToken = default)
{
var availability = await events.GetAvailabilityAsync(query.EventId, cancellationToken);
if (availability is null)
return EventErrors.NotFound(query.EventId);
return availability;
}
}
Esta consulta nunca carga la entidad Event. Le pide al puerto exactamente los tres valores que necesita la respuesta.
¿Por qué esa diferencia? Los comandos pasan por la entidad para que se ejecute su regla, y las consultas se la saltan. Esa separación tiene nombre, CQRS, y tiene su propia sección después de que ejecutemos la aplicación.
Por último, el proyecto registra sus propios handlers. Crea EventTicketing.Application/DependencyInjection.cs:
using Microsoft.Extensions.DependencyInjection;
namespace EventTicketing.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddScoped<ReserveTicketsHandler>();
services.AddScoped<GetEventAvailabilityHandler>();
return services;
}
}
Program.cs llamará a AddApplication() sin saber qué handlers existen. Cuando añadas un caso de uso, lo registras aquí, en el proyecto al que pertenece.
¿Hace falta MediatR para Clean Architecture?
No. Nuestros endpoints inyectarán el handler que necesiten y llamarán a HandleAsync(). Ese es todo el mecanismo de despacho, y el compilador comprueba cada llamada.
MediatR coloca un mediador entre el endpoint y el handler, y añade un pipeline.
El pipeline ejecuta código alrededor de cada solicitud, como logging, validación o una transacción, para que no lo repitas en cada handler.
Sin MediatR, puedes conseguir el mismo efecto con decoradores alrededor de los handlers o con filtros de endpoint en el lado de la API.
También está la cuestión de la licencia. Según las preguntas frecuentes sobre licencias del proveedor, MediatR 13.0.0 y las versiones posteriores tienen doble licencia.
Puedes usarlas con la Reciprocal Public License 1.5 sin costo si aceptas sus obligaciones recíprocas (copyleft), o comprar una licencia comercial.
Una licencia Community gratuita cubre a las organizaciones que cumplen las cuatro condiciones del proveedor. La versión 12.5.0, publicada en abril de 2025, es la última con licencia Apache 2.0.
MediatR sigue siendo una opción razonable cuando quieres su pipeline y la licencia te encaja. Clean Architecture no lo exige. Si prefieres ese camino, consulta nuestra guía de CQRS con MediatR.
Paso 4: añadir la persistencia en la capa de infraestructura
¿Dónde va la persistencia en Clean Architecture? En el proyecto Infrastructure. Ahí es donde vive EF Core, junto con cualquier otra cosa que se comunique con el mundo fuera del proceso, como archivos, colas, correo electrónico y otros servicios.
Primero, el DbContext. Crea EventTicketing.Infrastructure/TicketingDbContext.cs:
using EventTicketing.Domain;
using Microsoft.EntityFrameworkCore;
namespace EventTicketing.Infrastructure;
public sealed class TicketingDbContext(DbContextOptions<TicketingDbContext> options)
: DbContext(options)
{
public DbSet<Event> Events => Set<Event>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Event>(builder =>
{
builder.Property(e => e.Name).HasMaxLength(200);
builder.HasData(
new { Id = 1, Name = "Clean Architecture Live", Capacity = 100, TicketsSold = 0 },
new { Id = 2, Name = "Tiny Jazz Club Night", Capacity = 2, TicketsSold = 0 });
});
}
}
La clase Event no lleva ningún atributo de EF Core, y todo el mapeo vive aquí. Eso es lo que significa la ignorancia de la persistencia (persistence ignorance): la entidad no sabe cómo se almacena.
Aun así, EF Core la carga y la guarda, constructor privado y setters privados incluidos. HasData() precarga dos eventos para que tengamos algo que reservar: “Clean Architecture Live”, con 100 plazas, y “Tiny Jazz Club Night”, con dos.
A continuación, el repositorio implementa el puerto de Application. Crea EventTicketing.Infrastructure/EventRepository.cs:
using EventTicketing.Application;
using EventTicketing.Domain;
using Microsoft.EntityFrameworkCore;
namespace EventTicketing.Infrastructure;
public sealed class EventRepository(TicketingDbContext dbContext) : IEventRepository
{
public Task<Event?> GetByIdAsync(int eventId, CancellationToken cancellationToken = default) =>
dbContext.Events.FirstOrDefaultAsync(e => e.Id == eventId, cancellationToken);
public Task<EventAvailability?> GetAvailabilityAsync(int eventId, CancellationToken cancellationToken = default) =>
dbContext.Events
.AsNoTracking()
.Where(e => e.Id == eventId)
.Select(e => new EventAvailability(e.Id, e.Name, e.Capacity - e.TicketsSold))
.FirstOrDefaultAsync(cancellationToken);
public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
dbContext.SaveChangesAsync(cancellationToken);
}
Aquí, GetByIdAsync() carga un Event con seguimiento de cambios, para que EF Core detecte cuándo lo modifica Reserve(). GetAvailabilityAsync() proyecta directamente en el record de respuesta, con el seguimiento de cambios desactivado. SaveChangesAsync() escribe lo que haya cambiado.
Infrastructure registra sus servicios en un único método. Crea EventTicketing.Infrastructure/DependencyInjection.cs:
using EventTicketing.Application;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace EventTicketing.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services, string connectionString)
{
services.AddDbContext<TicketingDbContext>(options => options.UseSqlite(connectionString));
services.AddScoped<IEventRepository, EventRepository>();
return services;
}
}
AddInfrastructure() recibe la cadena de conexión como parámetro. Leer la configuración es tarea del host, así que Infrastructure nunca necesita saber de dónde viene la cadena.
También verás soluciones que ponen la persistencia en un proyecto Persistence aparte, junto a Infrastructure. Eso también funciona, ya que ambos proyectos están en el exterior y apuntan hacia dentro.
¿Conviene usar el patrón repositorio con EF Core?
Es una de las preguntas más debatidas de Clean Architecture, y las dos posturas tienen algo de razón.
Los argumentos en contra empiezan por el propio EF Core. La referencia de DbContext de Microsoft dice: “DbContext es una combinación de los patrones Unit Of Work y Repository”.
Así que un IRepository<T> genérico con GetAll(), Add(), Update() y Delete() para cada entidad copia DbSet<T> a mano, un método tras otro.
El argumento a favor es la regla de dependencia. Nuestro proyecto Application no hace referencia a EF Core, así que un handler no puede recibir un DbContext.
Un repositorio pequeño da a cada caso de uso una operación con nombre, mantiene EF Core fuera y da a tus pruebas un sitio donde enchufar un fake.
Nuestra regla: escribe un repositorio cuando mantenga EF Core fuera de las capas interiores y hable el lenguaje del caso de uso. Olvídate del genérico.
Nuestro repositorio también expone SaveChangesAsync(), lo cual está bien mientras cada caso de uso guarde sus cambios a través de un único repositorio. Cuando un caso de uso guarde cambios en varios repositorios, dale al guardado su propia interfaz. Ese es el patrón de unidad de trabajo.
Paso 5: exponer los casos de uso a través de la API
El proyecto Api es por donde llega HTTP. Convierte las solicitudes en comandos y consultas, llama a los handlers y convierte sus resultados en respuestas HTTP.
Empieza por el mapeo de errores. Crea EventTicketing.Api/ResultExtensions.cs:
using EventTicketing.Domain;
namespace EventTicketing.Api;
public static class ResultExtensions
{
public static IResult ToProblem(this Result result)
{
var error = result.Error
?? throw new InvalidOperationException("A successful result is not a problem.");
var statusCode = error.Type switch
{
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Conflict => StatusCodes.Status409Conflict,
_ => StatusCodes.Status500InternalServerError
};
return TypedResults.Problem(statusCode: statusCode, title: error.Code, detail: error.Description);
}
}
Ningún otro archivo de la solución mapea tipos de error a códigos de estado. Domain dice “conflicto”, y solo la frontera de la API sabe que un conflicto es un 409.
TypedResults.Problem() escribe un cuerpo en formato problem details, la forma JSON estándar para los errores de las API HTTP, y nuestro código de error va en title. Tienes más información sobre el formato en ProblemDetails en ASP.NET Core.
Ahora, los endpoints. Crea EventTicketing.Api/EventEndpoints.cs:
using System.ComponentModel.DataAnnotations;
using EventTicketing.Application;
namespace EventTicketing.Api;
public sealed record ReserveTicketsRequest([property: Range(1, 20)] int Quantity);
public static class EventEndpoints
{
public static void MapEventEndpoints(this IEndpointRouteBuilder app)
{
var events = app.MapGroup("/api/events");
events.MapGet("/{eventId:int}", async (
int eventId,
GetEventAvailabilityHandler handler,
CancellationToken cancellationToken) =>
{
var result = await handler.HandleAsync(new GetEventAvailabilityQuery(eventId), cancellationToken);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
});
events.MapPost("/{eventId:int}/reservations", async (
int eventId,
ReserveTicketsRequest request,
ReserveTicketsHandler handler,
CancellationToken cancellationToken) =>
{
var command = new ReserveTicketsCommand(eventId, request.Quantity);
var result = await handler.HandleAsync(command, cancellationToken);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
});
}
}
El cliente solo envía una cantidad, ya que el ID del evento viaja en la URL.
¿Por qué dos records que parecen iguales? ReserveTicketsRequest describe el cuerpo HTTP, y ReserveTicketsCommand describe la entrada del caso de uso. Hoy se parecen, pero más adelante pueden cambiar por motivos distintos.
Cada endpoint construye una consulta o un comando, llama al handler y convierte el Result en una respuesta. Aquí no hay lógica de negocio que tengas que probar.
La Api necesita una cadena de conexión para SQLite. Sustituye EventTicketing.Api/appsettings.json por:
{
"ConnectionStrings": {
"Ticketing": "Data Source=tickets.db"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Por último, Program.cs lo conecta todo. Sustituye EventTicketing.Api/Program.cs por:
using EventTicketing.Api;
using EventTicketing.Application;
using EventTicketing.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration.GetConnectionString("Ticketing")!);
builder.Services.AddProblemDetails();
builder.Services.AddValidation();
var app = builder.Build();
app.UseExceptionHandler();
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<TicketingDbContext>();
dbContext.Database.EnsureCreated();
}
app.MapEventEndpoints();
app.Run();
AddApplication() y AddInfrastructure() registran los servicios de cada capa.
AddProblemDetails() y UseExceptionHandler() convierten cualquier excepción no manejada en una respuesta 500 en formato problem details. Tienes más información sobre cómo manejar los errores en un solo lugar en nuestro artículo sobre el manejo global de errores.
AddValidation() activa la validación integrada para las API mínimas que llegó con .NET 10.
EnsureCreated() crea la base de datos SQLite y los dos eventos precargados en la primera ejecución. Para una demo está bien, pero una aplicación real usaría migraciones de EF Core en su lugar. Si EF Core es nuevo para ti, nuestra serie sobre EF Core empieza desde lo básico.
Program.cs es el único archivo que ve todas las capas. Eso es lo que lo convierte en la raíz de composición, y es el único motivo por el que la Api hace referencia a Infrastructure. Si la inyección de dependencias de ASP.NET Core es nueva para ti, empieza por ahí.
¿Por qué API mínimas y no controladores? La introducción a las API de Microsoft recomienda las API mínimas para los proyectos nuevos.
En Clean Architecture, un endpoint solo traduce entre HTTP y un caso de uso, así que basta con una lambda. Los controladores también funcionan y, si los prefieres, no cambia nada fuera del proyecto Api.
¿Dónde va la validación?
En dos lugares, porque “validación” abarca dos trabajos distintos.
La validación de entrada comprueba la forma de una solicitud. ¿La cantidad está entre 1 y 20? Eso no requiere conocer el negocio, así que va en la frontera, antes de que se ejecute un caso de uso.
Nuestro atributo [Range] junto con AddValidation() hace exactamente eso.
Una regla de negocio seguiría siendo cierta sin ninguna solicitud HTTP alrededor. “No podemos vender más entradas de las que tenemos” es una de ellas.
Va en Domain, dentro del método que cambia el estado. Por eso vive en Event.Reserve().
Entonces, ¿por qué Reserve() sigue lanzando una excepción con cero? Porque Domain no puede dar por hecho que todos los llamadores han validado. Un trabajo en segundo plano o un endpoint futuro podría llamar a Reserve(0), y eso sería un error del llamador.
En aplicaciones más grandes, a menudo verás que la validación de entrada pasa a la capa de aplicación con FluentValidation, para que un caso de uso reciba las mismas comprobaciones sin importar quién lo llame.
Para dos endpoints, bastan los atributos.
Paso 6: ejecutar la aplicación y enviar algunas solicitudes
Desde la carpeta de la solución, inicia la API en un puerto fijo:
dotnet run --project EventTicketing.Api -- --urls http://localhost:5000
Cuando la consola muestre Now listening on: http://localhost:5000, la API estará lista. En la primera ejecución, EnsureCreated() también crea EventTicketing.Api/tickets.db con nuestros dos eventos. Para empezar de cero más adelante, detén la aplicación y borra ese archivo.
La forma más fácil de enviar solicitudes es un archivo .http, que pueden ejecutar Visual Studio, Rider y VS Code (con la extensión REST Client). Crea EventTicketing.Api/EventTicketing.Api.http:
GET http://localhost:5000/api/events/1
###
POST http://localhost:5000/api/events/1/reservations
Content-Type: application/json
{ "quantity": 3 }
###
POST http://localhost:5000/api/events/2/reservations
Content-Type: application/json
{ "quantity": 3 }
###
POST http://localhost:5000/api/events/1/reservations
Content-Type: application/json
{ "quantity": 0 }
###
GET http://localhost:5000/api/events/99
La primera solicitud pregunta por el primer evento, y siguen quedando las 100 entradas:
{"eventId":1,"name":"Clean Architecture Live","ticketsLeft":100}
Reservar tres entradas devuelve el nuevo recuento:
{"eventId":1,"ticketsReserved":3,"ticketsLeft":97}
Ahora pidamos tres entradas para el Tiny Jazz Club Night, que tiene dos plazas. La respuesta es un 409 Conflict con un cuerpo en formato problem details:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "Event.SoldOut",
"status": 409,
"detail": "Only 2 tickets left, 3 requested.",
"traceId": "00-ce108ce4bc6489ca9b29b3ea9c78fe26-ebc9be53eb756f7b-00"
}
El fallo viajó desde Event.Reserve() a través del handler hasta el endpoint como un simple valor devuelto. No se lanzó nada y no se guardó nada.
Una cantidad de cero nunca llega a nuestro código. AddValidation() lee el atributo [Range] y responde con un 400 por su cuenta:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Quantity": [
"The field Quantity must be between 1 and 20."
]
},
"traceId": "00-ecfcd14241c21757f5f5ebe6d761e553-406ab76e75f2823e-00"
}
Y un evento desconocido vuelve de la misma forma que el agotado, como un 404 con el título Event.NotFound:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Event.NotFound",
"status": 404,
"detail": "Event 99 was not found.",
"traceId": "00-e0c75250298c4435886b8fcc54d7d64c-d2ac6be30f33ddae-00"
}
¿Cómo fluye una solicitud por las capas?
Sigamos la solicitud del evento agotado desde el cliente hasta la entidad y de vuelta:

El handler llama hacia fuera, a Infrastructure, en el paso 3, y aun así ningún archivo de Application nombra una clase de Infrastructure.
La respuesta de entradas agotadas del paso 6 es un valor devuelto normal. El handler se salta SaveChangesAsync() porque devuelve el resultado antes, así que nada llega a la base de datos.
¿Cómo encaja CQRS en Clean Architecture?
CQRS, siglas de Command Query Responsibility Segregation, separa el código que modifica los datos del código que los lee.
No necesitas dos bases de datos ni una biblioteca de mediador para aplicarlo. Una base de datos con dos caminos a través del código ya es CQRS. Nuestra aplicación tiene ambos caminos.
La reserva es un comando. Carga la entidad Event, porque la regla de capacidad tiene que ejecutarse, y después guarda.
La comprobación de disponibilidad es una consulta. Nunca toca la entidad y solo pide lo que muestra la respuesta.
La diferencia se ve en el SQL. EF Core registra en el log cada comando que ejecuta, así que la consola muestra ambos caminos. Esta es la consulta:
SELECT "e"."Id", "e"."Name", "e"."Capacity" - "e"."TicketsSold" FROM "Events" AS "e" WHERE "e"."Id" = @eventId LIMIT 1
Y esta es la reserva, que carga la entidad completa y después guarda la única columna que ha cambiado:
SELECT "e"."Id", "e"."Capacity", "e"."Name", "e"."TicketsSold" FROM "Events" AS "e" WHERE "e"."Id" = @eventId LIMIT 1 UPDATE "Events" SET "TicketsSold" = @p0 WHERE "Id" = @p1 RETURNING 1;
La consulta calcula las entradas restantes en la base de datos y no hace seguimiento de nada. El comando carga Event para que Reserve() pueda comprobar la regla, y después EF Core actualiza solo TicketsSold.
En una aplicación más grande, las lecturas suelen tener su propio puerto y sus propios modelos de lectura. La separación sigue siendo la misma.
Paso 7: probar las reglas sin base de datos
Las capas abaratan las pruebas. Añadamos un proyecto de pruebas junto a los demás:
dotnet new classlib -n EventTicketing.UnitTests dotnet solution add EventTicketing.UnitTests
Borra su Class1.cs y después sustituye EventTicketing.UnitTests/EventTicketing.UnitTests.csproj por:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EventTicketing.Application\EventTicketing.Application.csproj" />
</ItemGroup>
</Project>
El proyecto usa xUnit v3 a través del paquete xunit.v3.mtp-off, que mantiene el ejecutor de pruebas clásico, así que basta con un simple dotnet test.
Si en cambio creas los proyectos de pruebas con dotnet new xunit3, ten cuidado con el global.json que escribe o actualiza. Ese archivo cambia dotnet test a Microsoft Testing Platform, y además la plantilla tiene como destino net8.0.
Si ejecutas dotnet test desde una carpeta en la que no se encuentra ese archivo, el SDK de .NET 10 se detiene con este error:
error : Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later. If you use dotnet test, you should opt-in to the new dotnet test experience. For more information, see https://aka.ms/dotnet-test-mtp-error
Ahora, las pruebas. Las pruebas de dominio no necesitan nada más que la entidad. Crea EventTicketing.UnitTests/EventTests.cs:
using EventTicketing.Domain;
namespace EventTicketing.UnitTests;
public class EventTests
{
[Fact]
public void Reserve_WhenEnoughTicketsAreLeft_SellsThem()
{
var ev = Event.Create("Clean Architecture Live", capacity: 100);
var result = ev.Reserve(3);
Assert.True(result.IsSuccess);
Assert.Equal(97, ev.TicketsLeft);
}
[Fact]
public void Reserve_WhenTooFewTicketsAreLeft_ReturnsSoldOutAndSellsNothing()
{
var ev = Event.Create("Tiny Jazz Club Night", capacity: 2);
var result = ev.Reserve(3);
Assert.False(result.IsSuccess);
Assert.Equal("Event.SoldOut", result.Error!.Code);
Assert.Equal(0, ev.TicketsSold);
}
}
Estas pruebas crean un Event y llaman a su método Reserve(). No hay base de datos, ni biblioteca de mocks, ni código de preparación.
Las pruebas de Application sustituyen el puerto por un fake, una pequeña clase que escribimos nosotros mismos y que implementa el puerto con el código más sencillo que funcione. Crea EventTicketing.UnitTests/ReserveTicketsHandlerTests.cs:
using EventTicketing.Application;
using EventTicketing.Domain;
namespace EventTicketing.UnitTests;
public class ReserveTicketsHandlerTests
{
[Fact]
public async Task HandleAsync_WhenTicketsAreAvailable_ReservesAndSaves()
{
var events = new FakeEventRepository(Event.Create("Clean Architecture Live", capacity: 100));
var handler = new ReserveTicketsHandler(events);
var result = await handler.HandleAsync(
new ReserveTicketsCommand(EventId: 1, Quantity: 2), TestContext.Current.CancellationToken);
Assert.True(result.IsSuccess);
Assert.Equal(98, result.Value.TicketsLeft);
Assert.Equal(1, events.SaveCount);
}
[Fact]
public async Task HandleAsync_WhenEventIsSoldOut_SavesNothing()
{
var events = new FakeEventRepository(Event.Create("Tiny Jazz Club Night", capacity: 1));
var handler = new ReserveTicketsHandler(events);
var result = await handler.HandleAsync(
new ReserveTicketsCommand(EventId: 1, Quantity: 2), TestContext.Current.CancellationToken);
Assert.False(result.IsSuccess);
Assert.Equal("Event.SoldOut", result.Error!.Code);
Assert.Equal(0, events.SaveCount);
}
}
internal sealed class FakeEventRepository(Event? ev) : IEventRepository
{
public int SaveCount { get; private set; }
public Task<Event?> GetByIdAsync(int eventId, CancellationToken cancellationToken = default) =>
Task.FromResult(ev);
public Task<EventAvailability?> GetAvailabilityAsync(int eventId, CancellationToken cancellationToken = default) =>
Task.FromResult<EventAvailability?>(null);
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
SaveCount++;
return Task.CompletedTask;
}
}
FakeEventRepository devuelve el evento que le pasemos y cuenta cuántas veces guardó el handler. El handler no nota la diferencia:


Ebook gratis
¿Tu Web API está lista para producción?
33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.
El ebook está en inglés.
Descarga la checklist gratisPDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.
La primera prueba reserva dos de 100 entradas y comprueba que el handler guardó exactamente una vez. La segunda pide dos entradas cuando solo queda una y comprueba que no se guardó nada.
Ejecuta las pruebas desde la carpeta de la solución:
dotnet test
La salida termina con el resumen:
Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4, Duration: 127 ms - EventTicketing.UnitTests.dll (net10.0)
En nuestra máquina, las cuatro pruebas se ejecutaron en 127 milisegundos. Ninguna necesitó una base de datos.
Podrías usar una biblioteca de mocks en lugar de fakes. Para un puerto tan pequeño, el fake ocupa 16 líneas y se lee como el código al que sustituye. Si prefieres una biblioteca, consulta nuestra guía de NSubstitute.
Las aserciones usan la propia clase Assert de xUnit. No usamos FluentAssertions porque la versión 8, publicada en enero de 2025, es de pago para uso comercial. La versión 7 sigue estando bajo Apache 2.0.
Las pruebas unitarias no pueden demostrar que las piezas encajan: el mapeo de EF Core, los registros de DI o el JSON. Ese es el trabajo de las pruebas de integración con WebApplicationFactory, que este artículo deja fuera.
Paso 8: hacer cumplir la regla de dependencia con pruebas de arquitectura
El compilador ya hace cumplir la mitad. Rompamos la regla a propósito, con un cambio que no deberías conservar en tu propio código.
Añadimos al principio de Event.cs una línea using que apunta hacia fuera:
using EventTicketing.Application;
La compilación falla:
Event.cs(1,22): error CS0234: The type or namespace name 'Application' does not exist in the namespace 'EventTicketing' (are you missing an assembly reference?)
Domain no tiene ninguna referencia a Application, así que el compilador ni siquiera ve el espacio de nombres. Las referencias entre proyectos convierten la regla de dependencia en un error de compilación.
Entonces, ¿para qué necesitamos pruebas? Porque una referencia entre proyectos no es la única vía de entrada.
Nada impide que alguien añada el paquete de EF Core directamente a Application y escriba una clase como esta. Es justo la fuga que hay que detectar, así que no hagas esto:
using EventTicketing.Domain;
using Microsoft.EntityFrameworkCore;
namespace EventTicketing.Application;
public sealed class SneakyReportHandler(DbContext dbContext)
{
public Task<int> CountEventsAsync(CancellationToken cancellationToken = default) =>
dbContext.Set<Event>().CountAsync(cancellationToken);
}
Esta vez la compilación funciona. Un proyecto puede hacer referencia a cualquier paquete NuGet que quiera, y el compilador no sabe qué paquetes rompen nuestra arquitectura.
Las pruebas de arquitectura cierran ese hueco. Añadamos un segundo proyecto de pruebas:
dotnet new classlib -n EventTicketing.ArchitectureTests dotnet solution add EventTicketing.ArchitectureTests
Borra su Class1.cs y después sustituye EventTicketing.ArchitectureTests/EventTicketing.ArchitectureTests.csproj por:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="NetArchTest.Rules" Version="1.3.2" />
<PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EventTicketing.Application\EventTicketing.Application.csproj" />
</ItemGroup>
</Project>
El proyecto hace referencia a Application, que arrastra consigo a Domain, y añade NetArchTest.Rules. Esa biblioteca lee los ensamblados compilados y comprueba de qué depende cada tipo. Crea EventTicketing.ArchitectureTests/DependencyRuleTests.cs:
using EventTicketing.Application;
using EventTicketing.Domain;
using NetArchTest.Rules;
namespace EventTicketing.ArchitectureTests;
public class DependencyRuleTests
{
[Fact]
public void Domain_DependsOnNoOtherLayer()
{
var result = Types.InAssembly(typeof(Event).Assembly)
.ShouldNot()
.HaveDependencyOnAny("EventTicketing.Application", "EventTicketing.Infrastructure", "EventTicketing.Api")
.GetResult();
Assert.True(result.IsSuccessful, "Offending types: " + string.Join(", ", result.FailingTypeNames ?? []));
}
[Fact]
public void Application_DoesNotDependOnInfrastructureOrTheWeb()
{
var result = Types.InAssembly(typeof(ReserveTicketsHandler).Assembly)
.ShouldNot()
.HaveDependencyOnAny("EventTicketing.Infrastructure", "EventTicketing.Api",
"Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore")
.GetResult();
Assert.True(result.IsSuccessful, "Offending types: " + string.Join(", ", result.FailingTypeNames ?? []));
}
}
Cada prueba carga el ensamblado de una capa a través de un tipo que vive en él, como Event para Domain. HaveDependencyOnAny() enumera los espacios de nombres que la capa no debe usar, y GetResult() informa de cada tipo que rompe la regla.
Con la clase tramposa en su sitio, la ejecución de las pruebas falla y la nombra:
Failed EventTicketing.ArchitectureTests.DependencyRuleTests.Application_DoesNotDependOnInfrastructureOrTheWeb [312 ms] Error Message: Offending types: EventTicketing.Application.SneakyReportHandler Failed! - Failed: 1, Passed: 1, Skipped: 0, Total: 2, Duration: 366 ms - EventTicketing.ArchitectureTests.dll (net10.0)
Borra la clase y la referencia al paquete, y las dos pruebas vuelven a pasar. Si las ejecutas en CI junto a las pruebas unitarias, la regla se mantiene sin que nadie tenga que detectar la fuga en una revisión de código.
Así se reparten el trabajo los dos guardianes:

NetArchTest lee el código compilado, así que ve los tipos que una clase usa realmente. Una directiva using sin usar no deja nada en el ensamblado, así que la prueba no puede verla, y la directiva tampoco puede hacer ningún daño.
NetArchTest.Rules no ha publicado ninguna versión desde la 1.3.2, de mayo de 2021, y sigue funcionando bien en .NET 10, como muestra la salida anterior.
Si prefieres una biblioteca con versiones recientes, ArchUnitNET es una alternativa. Su última versión salió en agosto de 2026.
¿Cómo queda la solución terminada?
Esta es la solución terminada, con los archivos que hemos creado en cada proyecto:
EventTicketing.slnx
EventTicketing.Domain/
Event.cs, EventErrors.cs, Result.cs
EventTicketing.Application/
DependencyInjection.cs, GetEventAvailability.cs, IEventRepository.cs, ReserveTickets.cs
EventTicketing.Infrastructure/
DependencyInjection.cs, EventRepository.cs, TicketingDbContext.cs
EventTicketing.Api/
EventEndpoints.cs, EventTicketing.Api.http, Program.cs, ResultExtensions.cs, appsettings.json
EventTicketing.UnitTests/
EventTests.cs, ReserveTicketsHandlerTests.cs
EventTicketing.ArchitectureTests/
DependencyRuleTests.cs
Domain no sabe nada de los otros tres, y las pruebas lo demuestran en cada ejecución.
¿Dónde va mi código?
Las cuatro capas quedan ordenadas en un diagrama, pero el código real es más desordenado. ¿Dónde va una llamada a un proveedor de pagos? ¿Y el ID del usuario actual? ¿Y la hora actual?
Nos hacemos cuatro preguntas en orden y paramos en el primer sí:

Cuando parezca que un código pertenece a dos capas, comprueba si en realidad está haciendo dos trabajos. La validación fue el ejemplo que ya vimos: la comprobación de la forma de la solicitud está en la frontera, y la regla de capacidad está en Domain.
Aquí es donde acaban las piezas de código más habituales:
| Código | Capa | Por qué va ahí |
|---|---|---|
Las entidades y las reglas que protegen, como Event.Reserve() | Domain | La regla seguiría siendo cierta sin ninguna aplicación alrededor |
Objetos de valor, como un tipo Money | Domain | Un concepto de negocio que valida sus propios valores |
Errores de dominio, como EventErrors.SoldOut | Domain | La regla que falla sabe qué salió mal |
| Leer la hora actual | Domain o Application, a través de TimeProvider | .NET 8 añadió TimeProvider, así que ninguna capa necesita su propia interfaz de reloj |
Handlers de casos de uso, como ReserveTicketsHandler | Application | Ordenan los pasos de una operación |
| Interfaces de repositorio y de unidad de trabajo | Application | Pertenecen a los casos de uso que las llaman |
| Una interfaz para el usuario actual | Application | El caso de uso necesita el usuario, y la Api implementa la interfaz a partir de HttpContext |
DbContext, mapeos de EF Core y repositorios | Infrastructure | Se comunican con la base de datos |
| Clientes de correo electrónico, de pagos y de almacenamiento de archivos | Infrastructure | Se comunican con algo fuera del proceso |
| Endpoints y records de solicitud | Api | Existen porque los clientes hablan HTTP |
Mapear ErrorType a códigos de estado | Api | Solo la frontera sabe qué es un 409 |
Registrar servicios en Program.cs | Api | La raíz de composición es el único lugar donde se juntan los servicios de todas las capas |
La tabla es un punto de partida. Cuando algo no encaje, ponle nombre a lo que hace el código antes de decidir dónde vive.
¿Cuáles son los errores más comunes en Clean Architecture?
Estos cinco pasan la revisión de código y aun así te hacen daño más adelante.
Un modelo de dominio anémico
Entidades con setters públicos y sin comportamiento, más una clase de servicio que contiene las reglas. Nada impide que el siguiente handler se salte el servicio.
Es el endpoint enmarañado del principio de este artículo, repartido en más archivos.
Lógica de negocio en el handler
Un handler que comprueba la capacidad por sí mismo ha asumido el trabajo de la entidad. Esta es la versión que hay que evitar:
if (ev.TicketsSold + command.Quantity > ev.Capacity)
return EventErrors.SoldOut(ev.TicketsLeft, command.Quantity);
ev.TicketsSold += command.Quantity;
Con nuestro Event, ni siquiera compila:
ReserveTickets.cs(21,9): error CS0200: Property or indexer 'Event.TicketsSold' cannot be assigned to -- it is read only
El setter privado es lo que hace imposible saltarse la regla. Cuando un handler necesita los datos de una entidad para tomar una decisión, la decisión suele corresponder a la entidad.
Un repositorio genérico
Un IRepository<T> genérico copia DbSet<T> a mano y no nombra ninguna de las operaciones que realizan tus casos de uso. En su lugar, da a cada agregado un repositorio pequeño con métodos con nombre.
Devolver IQueryable desde un repositorio
Un método de repositorio que devuelve IQueryable<Event> le devuelve a Application el proveedor de consultas de EF Core.
Las consultas empiezan a crecer dentro de los handlers, y el límite para el que se construyó el puerto desaparece. Devuelve resultados o modelos de lectura en su lugar.
Una interfaz para todo
Una interfaz se gana su sitio cuando algo necesita variar detrás de ella, como un repositorio real y un fake.
Nuestros handlers no tienen interfaces, y las pruebas no las echan de menos.
¿Cuándo no conviene usar Clean Architecture?
Cuando tu aplicación no tiene reglas de negocio que valga la pena proteger.
Clean Architecture tiene un costo diario. Añadir una característica toca cuatro proyectos, y seguir una solicitud implica abrir cinco o seis archivos.
Incluso una lectura sencilla pasa por una consulta, un handler, un puerto y un repositorio.
Ese costo compensa cuando el dominio tiene reglas que nunca deben romperse, como las reservas y los pagos.
No compensa en una herramienta de administración CRUD ni en un servicio pequeño que copia datos de un sitio a otro.
La principal alternativa es Vertical Slice Architecture, que organiza el código por característica en lugar de por capa. Estas son nuestras dos características, cortadas de las dos maneras:

Un slice mantiene en un solo lugar el endpoint, la lógica y el acceso a datos de una característica.
Esta es una característica que nuestro ejemplo no tiene, una lista de eventos con entradas disponibles, escrita como un slice:
using EventTicketing.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace EventTicketing.Api;
public static class ListAvailableEvents
{
public sealed record AvailableEvent(int EventId, string Name, int TicketsLeft);
public static void MapListAvailableEvents(this IEndpointRouteBuilder app) =>
app.MapGet("/api/events", async (TicketingDbContext dbContext, CancellationToken cancellationToken) =>
await dbContext.Events
.AsNoTracking()
.Where(e => e.TicketsSold < e.Capacity)
.Select(e => new AvailableEvent(e.Id, e.Name, e.Capacity - e.TicketsSold))
.ToListAsync(cancellationToken));
}
Es un solo archivo, sin handler, sin puerto y sin repositorio, y el endpoint consulta TicketingDbContext directamente.
Lo ejecutamos dentro de nuestro ejemplo y, después de agotar las entradas de la noche de jazz, GET /api/events devolvió solo el otro evento:
[{"eventId":1,"name":"Clean Architecture Live","ticketsLeft":100}]
El slice paga su brevedad rompiendo una de nuestras reglas. Vive en el proyecto Api y usa directamente una clase de Infrastructure, algo que nuestros endpoints por capas nunca hacen.
Para una lectura sin reglas de negocio, es una contrapartida razonable.
No tienes que elegir un solo estilo para toda la aplicación.
Un término medio habitual es mantener un núcleo protegido para las características con reglas reales, como nuestras reservas, y escribir las lecturas sencillas como slices finos.
Nuestro artículo sobre Vertical Slice Architecture muestra el estilo completo. Y cuando una aplicación crece hasta abarcar varias áreas de negocio, un monolito modular permite que cada módulo elija el estilo que mejor le encaje.
¿Cuántos proyectos necesitamos?
Cuatro es una convención. La guía de Microsoft sobre arquitecturas comunes de aplicaciones web usa tres: un proyecto Application Core que contiene el modelo de negocio y las interfaces, más Infrastructure y un proyecto de interfaz de usuario.
Lo que aportan los proyectos separados es la garantía de cumplimiento. El compilador rechaza el código que apunta hacia fuera, como vimos con CS0234.
En un solo proyecto, puedes mantener la misma regla con carpetas, espacios de nombres y pruebas de arquitectura, ya que NetArchTest también puede comprobar espacios de nombres.
Nosotros empezaríamos con cuatro proyectos cuando el dominio tiene reglas reales y trabaja en él más de una persona. Para un servicio pequeño, un proyecto con carpetas y unas cuantas pruebas de arquitectura es más que suficiente.
¿En qué se diferencia Clean Architecture de Onion, Hexagonal y N capas?
Onion Architecture y Hexagonal Architecture, también llamada puertos y adaptadores, comparten la idea central: el código de negocio en el centro, con las dependencias apuntando hacia dentro.
Se diferencian sobre todo en los nombres y en dónde trazan los límites interiores.
Comparamos Onion y Clean Architecture punto por punto en un artículo aparte. Nuestro artículo sobre Hexagonal Architecture explica los puertos y adaptadores en C#.
La arquitectura en N capas es la que no encaja. Apila la presentación sobre la lógica de negocio y esta sobre el acceso a datos, y todas las dependencias apuntan hacia abajo, hacia la base de datos.
Clean Architecture puede tener el mismo número de recuadros, pero les da la vuelta a las flechas, así que la base de datos acaba en el exterior.
¿Clean Architecture es lo mismo que Clean Code?
No. Ambos nombres vienen de Robert C. Martin, y por eso se confunden.
Clean Code trata del interior de una clase: nombres claros y funciones pequeñas y legibles.
Clean Architecture trata de los límites entre las partes de una aplicación y de la dirección de las dependencias entre ellas. Un código puede tener una cosa sin la otra.
Conclusión
Hemos construido una solución con Clean Architecture en C# partiendo de una carpeta vacía. Por el camino, hemos visto:
- La regla de dependencia, y por qué las llamadas en tiempo de ejecución pueden viajar hacia fuera mientras las referencias apuntan hacia dentro
- Cuatro proyectos: un Domain que protege su regla, un Application con casos de uso y un puerto, un Infrastructure con EF Core y una Api que lo conecta todo
- El patrón Result para los fallos esperados, convertido en problem details en la frontera
- Comandos y consultas como dos caminos por el mismo código, que es CQRS sin mediador
- Pruebas unitarias con un fake y pruebas de arquitectura que fallan en cuanto una capa tiene una fuga
Nuestro ejemplo se queda en una sola característica.
Si quieres ver el sistema completo, nuestro curso de Clean Architecture en .NET construye la misma aplicación de venta de entradas desde un punto de partida enmarañado hasta una solución totalmente probada.
Incluye un mediador y un pipeline hechos a mano, eventos de dominio que se despachan después de confirmar la transacción, una característica de compra y reembolso, cuatro conjuntos de pruebas y un análisis imparcial de Vertical Slice Architecture.
Si vas a introducir Clean Architecture en una aplicación existente, empieza por las pruebas de arquitectura. Si las apuntas a tus proyectos actuales, te mostrarán hacia dónde apuntan hoy las flechas.
Probado con .NET 10.0.10, EF Core 10.0.12, xUnit v3 4.0.1 y NetArchTest.Rules 1.3.2.