
Clean Architecture y Patrón Repository en ASP.NET Core
¿Cuántas veces has abierto un proyecto de hace seis meses y no has sabido por dónde empezar? ¿O has querido cambiar la base de datos y has terminado tocando diez archivos distintos porque todo estaba mezclado? Ese es exactamente el problema que resuelve Clean Architecture: separar las responsabilidades de tu aplicación de forma que cada parte viva donde le corresponde, y que el núcleo de tu lógica de negocio no dependa de ningún framework externo.
En este artículo construimos desde cero una API de gestión de tareas llamada CleanTodo usando ASP.NET Core, MediatR, EF Core y SQLite, aplicando Clean Architecture y el Patrón Repository. Si sigues los pasos al pie de la letra, tendrás un proyecto funcionando con Swagger al final.
Índice
- El problema: aplicaciones sin estructura
- La solución: Clean Architecture
- Las cuatro capas del proyecto
- Paso a paso: creando CleanTodo desde cero
- Problemas encontrados y cómo resolverlos
- Recomendaciones y debate abierto
1. El problema: aplicaciones sin estructura
Cuando empezamos a programar, la tendencia natural es poner todo en el controlador: la lógica de negocio, las consultas a base de datos, las validaciones. Funciona. Al principio.
El problema llega cuando el proyecto crece. Cambiar SQLite por PostgreSQL implica tocar el controlador. Añadir tests unitarios es casi imposible porque todo depende de EF Core. Un compañero nuevo tarda días en entender dónde vive cada cosa.
Los puntos clave del problema son:
- Acoplamiento entre capas: la lógica de negocio conoce los detalles de infraestructura.
- Dificultad para testear: no se puede probar la lógica sin levantar la base de datos.
- Fragilidad ante cambios: tocar una pieza rompe otras que no deberían verse afectadas.
Clean Architecture, popularizada por Robert C. Martin (Uncle Bob) en su libro Clean Architecture: A Craftsman’s Guide, propone invertir esta situación: el dominio no depende de nadie, todos dependen del dominio.
2. La solución: Clean Architecture
La regla de dependencia es simple: las dependencias solo pueden apuntar hacia adentro.
API → Application → Domain ✅
Infrastructure → Domain ✅
Domain → Infrastructure ❌ nunca
Domain → Application ❌ nunca
Esto se consigue a través de interfaces. El dominio define un contrato (ITaskRepository). La infraestructura lo implementa (TaskRepository con EF Core). El dominio jamás sabe que EF Core existe.
El Patrón Repository actúa como intermediario entre la lógica de negocio y el acceso a datos. En lugar de hacer queries directamente en los handlers o controladores, todo pasa por una interfaz que abstrae el origen de los datos.
Para más contexto sobre este patrón, la documentación oficial de Microsoft lo explica en detalle: Repository pattern – .NET docs.
3. Las cuatro capas del proyecto
CleanTodo/
└── src/
├── CleanTodo.Domain/ ← Entidades, interfaces, excepciones
├── CleanTodo.Application/ ← Casos de uso, Commands, Queries, DTOs
├── CleanTodo.Infrastructure/ ← EF Core, repositorios, DbContext
└── CleanTodo.API/ ← Controladores, middleware, Program.cs
| Capa | Responsabilidad | NuGet |
|---|---|---|
| Domain | Lógica de negocio pura | ninguno |
| Application | Casos de uso, orquestación | MediatR, FluentValidation |
| Infrastructure | Acceso a datos | EF Core, SQLite |
| API | Entrada HTTP | Swashbuckle |
4. Paso a paso: creando CleanTodo desde cero
Requisitos previos
- .NET 10 SDK instalado
- Un editor de código (Visual Studio Code o Visual Studio)
- PowerShell o terminal
Paso 1 — Crear la solución y los proyectos
mkdir CleanTodo
cd CleanTodo
dotnet new sln -n CleanTodo
dotnet new classlib -n CleanTodo.Domain -o src/CleanTodo.Domain
dotnet new classlib -n CleanTodo.Application -o src/CleanTodo.Application
dotnet new classlib -n CleanTodo.Infrastructure -o src/CleanTodo.Infrastructure
dotnet new webapi -n CleanTodo.API -o src/CleanTodo.API
Añadimos los proyectos a la solución:
dotnet sln add src/CleanTodo.Domain/CleanTodo.Domain.csproj
dotnet sln add src/CleanTodo.Application/CleanTodo.Application.csproj
dotnet sln add src/CleanTodo.Infrastructure/CleanTodo.Infrastructure.csproj
dotnet sln add src/CleanTodo.API/CleanTodo.API.csproj
Paso 2 — Establecer referencias entre proyectos
Aquí es donde se refleja la regla de dependencia:
# Application conoce Domain
dotnet add src/CleanTodo.Application reference src/CleanTodo.Domain/CleanTodo.Domain.csproj
# Infrastructure conoce Domain
dotnet add src/CleanTodo.Infrastructure reference src/CleanTodo.Domain/CleanTodo.Domain.csproj
# API conoce Application e Infrastructure
dotnet add src/CleanTodo.API reference src/CleanTodo.Application/CleanTodo.Application.csproj
dotnet add src/CleanTodo.API reference src/CleanTodo.Infrastructure/CleanTodo.Infrastructure.csproj
Paso 3 — Instalar paquetes NuGet
# Application
dotnet add src/CleanTodo.Application package MediatR
dotnet add src/CleanTodo.Application package FluentValidation
# Infrastructure
dotnet add src/CleanTodo.Infrastructure package Microsoft.EntityFrameworkCore
dotnet add src/CleanTodo.Infrastructure package Microsoft.EntityFrameworkCore.Sqlite
dotnet add src/CleanTodo.Infrastructure package Microsoft.EntityFrameworkCore.Design
# API
dotnet add src/CleanTodo.API package Swashbuckle.AspNetCore
dotnet add src/CleanTodo.API package MediatR
dotnet add src/CleanTodo.API package FluentValidation.AspNetCore
⚠️ Importante: No instales
MediatR.Extensions.Microsoft.DependencyInjection— ese paquete es de versiones antiguas y entra en conflicto con MediatR 14. En la versión actual, el registro de DI ya está incluido en el paquete principal.
Paso 4 — Crear las carpetas
# Domain
New-Item -ItemType Directory -Path src/CleanTodo.Domain/Entities
New-Item -ItemType Directory -Path src/CleanTodo.Domain/Interfaces
New-Item -ItemType Directory -Path src/CleanTodo.Domain/Exceptions
# Application
New-Item -ItemType Directory -Path src/CleanTodo.Application/Tasks/Commands/CreateTask
New-Item -ItemType Directory -Path src/CleanTodo.Application/Tasks/Commands/CompleteTask
New-Item -ItemType Directory -Path src/CleanTodo.Application/Tasks/Commands/DeleteTask
New-Item -ItemType Directory -Path src/CleanTodo.Application/Tasks/Queries/GetAllTasks
New-Item -ItemType Directory -Path src/CleanTodo.Application/Tasks/Queries/GetTaskById
New-Item -ItemType Directory -Path src/CleanTodo.Application/DTOs
# Infrastructure
New-Item -ItemType Directory -Path src/CleanTodo.Infrastructure/Persistence/Repositories
New-Item -ItemType Directory -Path src/CleanTodo.Infrastructure/Persistence/Configurations
# API
New-Item -ItemType Directory -Path src/CleanTodo.API/Controllers
New-Item -ItemType Directory -Path src/CleanTodo.API/Middleware
Paso 5 — La capa Domain
Esta es la capa más importante. Abre el CleanTodo.Domain.csproj cuando termines y verás que no tiene ninguna referencia a NuGet. Eso es exactamente lo que buscamos.
TodoTask.cs — La entidad tiene lógica propia. No es un simple contenedor de datos:
namespace CleanTodo.Domain.Entities;
public class TodoTask
{
public Guid Id { get; private set; }
public string Title { get; private set; } = string.Empty;
public string? Description { get; private set; }
public bool IsCompleted { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? CompletedAt { get; private set; }
private TodoTask() { }
public static TodoTask Create(string title, string? description = null)
{
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentException("El titulo no puede estar vacio.", nameof(title));
return new TodoTask
{
Id = Guid.NewGuid(),
Title = title.Trim(),
Description = description?.Trim(),
IsCompleted = false,
CreatedAt = DateTime.UtcNow
};
}
// Regla de negocio: no se puede completar una tarea ya completada
public void Complete()
{
if (IsCompleted)
throw new InvalidOperationException($"La tarea '{Title}' ya esta completada.");
IsCompleted = true;
CompletedAt = DateTime.UtcNow;
}
}
ITaskRepository.cs — El contrato. Vive en Domain, lo implementa Infrastructure:
using CleanTodo.Domain.Entities;
namespace CleanTodo.Domain.Interfaces;
public interface ITaskRepository
{
Task<IEnumerable<TodoTask>> GetAllAsync(CancellationToken ct = default);
Task<TodoTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task AddAsync(TodoTask task, CancellationToken ct = default);
Task UpdateAsync(TodoTask task, CancellationToken ct = default);
Task DeleteAsync(TodoTask task, CancellationToken ct = default);
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
TaskNotFoundException.cs:
namespace CleanTodo.Domain.Exceptions;
public class TaskNotFoundException : Exception
{
public TaskNotFoundException(Guid id)
: base($"No se encontro ninguna tarea con Id '{id}'.") { }
}
Paso 6 — La capa Application
Aquí usamos CQRS con MediatR: los Commands modifican estado, las Queries solo leen. Para profundizar en CQRS, Jimmy Bogard tiene una serie excelente sobre este enfoque.
CreateTaskCommand.cs y su Handler:
// Command
public record CreateTaskCommand(string Title, string? Description) : IRequest<TaskDto>;
// Handler
public class CreateTaskHandler : IRequestHandler<CreateTaskCommand, TaskDto>
{
private readonly ITaskRepository _repository;
public CreateTaskHandler(ITaskRepository repository) => _repository = repository;
public async Task<TaskDto> Handle(CreateTaskCommand request, CancellationToken ct)
{
var task = TodoTask.Create(request.Title, request.Description);
await _repository.AddAsync(task, ct);
await _repository.SaveChangesAsync(ct);
return new TaskDto(task.Id, task.Title, task.Description,
task.IsCompleted, task.CreatedAt, task.CompletedAt);
}
}
El momento clave del proyecto — CompleteTaskHandler.cs:
public async Task Handle(CompleteTaskCommand request, CancellationToken ct)
{
var task = await _repository.GetByIdAsync(request.Id, ct)
?? throw new TaskNotFoundException(request.Id);
task.Complete(); // ← La lógica vive en la entidad, no aquí
await _repository.UpdateAsync(task, ct);
await _repository.SaveChangesAsync(ct);
}
El handler no sabe qué hace Complete() por dentro. Solo coordina. Esa separación es la esencia del dominio rico.
Paso 7 — La capa Infrastructure
TaskRepository.cs — Aquí sí usamos EF Core. Solo aquí:
public class TaskRepository : ITaskRepository
{
private readonly AppDbContext _context;
public TaskRepository(AppDbContext context) => _context = context;
public async Task<IEnumerable<TodoTask>> GetAllAsync(CancellationToken ct = default)
=> await _context.Tasks.OrderByDescending(t => t.CreatedAt).ToListAsync(ct);
public async Task<TodoTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await _context.Tasks.FindAsync([id], ct);
// ... resto de métodos
}
DependencyInjection.cs — El punto de conexión entre la interfaz y la implementación:
public static IServiceCollection AddInfrastructure(
this IServiceCollection services, string connectionString)
{
services.AddDbContext<AppDbContext>(opt => opt.UseSqlite(connectionString));
services.AddScoped<ITaskRepository, TaskRepository>(); // ← La magia
return services;
}
Paso 8 — La capa API
El controlador es deliberadamente delgado. Su único trabajo es recibir HTTP y delegar:
[HttpPut("{id:guid}/complete")]
public async Task<IActionResult> Complete(Guid id, CancellationToken ct)
{
await _mediator.Send(new CompleteTaskCommand(id), ct);
return NoContent(); // sin lógica, sin if, sin nada
}
Program.cs — El punto de entrada registra todo:
builder.Services.AddInfrastructure("Data Source=cleantodo.db");
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(CreateTaskCommand).Assembly));
builder.Services.AddValidatorsFromAssembly(typeof(CreateTaskCommand).Assembly);
Paso 9 — Arrancar y probar
dotnet build
dotnet run --project src/CleanTodo.API
Abre el navegador en http://localhost:5217/swagger. Puedes probar en este orden para el video:
POST /api/tasks— Crea una tareaGET /api/tasks— Lista todasPUT /api/tasks/{id}/complete— Completa la tareaPUT /api/tasks/{id}/complete— Vuelve a intentarlo → 409 Conflict ← aquí se ve la regla de negocio
5. Problemas encontrados
Conflicto de versiones con MediatR. Al instalar MediatR.Extensions.Microsoft.DependencyInjection junto con MediatR 14, el sistema de restauración de paquetes devuelve un error NU1107. La solución es no instalar ese paquete: desde MediatR 12 en adelante, el registro de DI está integrado. Solo necesitas MediatR y registrar con cfg.RegisterServicesFromAssembly(...).
EF Core 10 y las propiedades con setter privado. EF Core necesita un constructor privado sin parámetros para poder materializar las entidades. Sin él, al ejecutar una query obtendrás una excepción en tiempo de ejecución. El constructor privado vacío en TodoTask soluciona esto.
Puerto de la API. dotnet new webapi en .NET 10 ya no genera WeatherForecastController, y el puerto por defecto puede variar. Revisa el archivo launchSettings.json en Properties/ si Swagger no aparece donde esperas.
6. Recomendaciones y debate abierto
Si vas a usar esto en producción, añade un ValidationBehaviour de MediatR que intercepte los commands antes de llegar al handler y ejecute FluentValidation automáticamente. Es el pipeline behavior estándar en proyectos Clean Architecture y evita repetir la lógica de validación en cada handler.
¿Cuándo NO usar Clean Architecture? Es una pregunta legítima. Para una API pequeña con tres endpoints y un solo desarrollador, cuatro proyectos y veinte archivos puede ser sobredimensionado. El propio Uncle Bob advierte que la arquitectura debe adaptarse al contexto. Para proyectos grandes, en equipo o con expectativa de crecer, la inversión merece la pena.
Puntos para debatir:
- ¿Tiene sentido usar CQRS en un proyecto sin alta concurrencia o sin necesidad de separar modelos de lectura y escritura?
- ¿El Patrón Repository añade una capa de abstracción útil cuando ya tienes EF Core, que de por sí es una abstracción?
- ¿Merece la pena la complejidad inicial de Clean Architecture en proyectos de corta duración?
Hay posiciones encontradas en la comunidad. Mark Seemann escribió sobre los tradeoffs y vale la pena leerlo antes de adoptar cualquier patrón como dogma.
Conclusiones
La clave de este proyecto no está en los archivos en sí, sino en lo que no ocurre: el dominio no referencia a EF Core, el controlador no tiene lógica de negocio, y si mañana decides cambiar SQLite por PostgreSQL solo tienes que modificar DependencyInjection.cs y el connection string.
Eso es exactamente lo que busca Clean Architecture: que los cambios sean locales, predecibles y baratos.
Autora: Heily Madelay Ajila Tandazo Máster: Desarrollo Full Stack + Arquitecturas Cloud Centro: Tajamar Tech Año académico: 2025-2026 Código / recursos utilizados:
- Repositorio: HeilyMadelay-hub/master-portfolio at demo-tech-riders
- Documentación oficial .NET: https://learn.microsoft.com/en-us/dotnet/architecture/
- Clean Architecture (Uncle Bob): https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
- MediatR docs: https://github.com/jbogard/MediatR/wiki
- Vídeo: Clean Architecture y Patrón Repository en ASP.NET Core – Heily Madelay Ajila Tandazo.mp4