Clean Architecture en .NET (sin dogmas)
No necesitas MediatR ni frameworks complejos para aplicar Clean Architecture. Lo que necesitas son lĂmites claros y buen criterio.
Qué es Clean Architecture (y por qué los equipos la usan)
Clean Architecture es una forma de estructurar el cĂłdigo para que las reglas de negocio queden protegidas de lo que cambia frecuentemente: frameworks, bases de datos, interfaces de usuario y servicios externos.
La idea central es sencilla:
- Las dependencias apuntan hacia adentro (las reglas de negocio no deben depender de detalles externos).
- Los casos de uso son explĂcitos (la lĂłgica de tu aplicaciĂłn no está “escondida” dentro de controladores o repositorios).
- La infraestructura es reemplazable (puedes cambiar SQL por Postgres, memoria por Redis, o un cliente HTTP real por uno falso en los tests).
Por eso los equipos la adoptan: facilita que los sistemas evolucionen de forma segura a medida que crecen.
Las 4 capas (explicadas como humanos)
Clean Architecture suele representarse como cĂrculos concĂ©ntricos. En la práctica, la mayorĂa de proyectos .NET la organizan en cuatro capas:

1) API (o PresentaciĂłn)
Qué hace: se ocupa únicamente de HTTP.
- recibe peticiones
- valida la forma del request (validación básica)
- devuelve respuestas y cĂłdigos de estado
- delega el trabajo de negocio a la capa de Application
Qué NO debe hacer: reglas de negocio, llamadas a base de datos, orquestación compleja.
La capa de API debe ser un mecanismo de entrega delgado, no el cerebro del sistema.
2) Application (Casos de uso)
Qué hace: el comportamiento de la aplicación (los “verbos” de tu sistema).
CreateMovieGetMovieDetailsSearchMovies
Esta capa orquesta el trabajo: llama a repositorios, invoca servicios, aplica polĂticas y retorna resultados.
Objetivo principal: que los casos de uso sean fáciles de leer y fáciles de testear.
3) Domain (Reglas de negocio)
Qué hace: la “verdad” del negocio.
- entidades
- invariantes
- value objects
- reglas de dominio que deben cumplirse siempre
Objetivo principal: cero dependencias de frameworks o infraestructura.
Si tu dominio importa EF Core, ASP.NET o cualquier cosa relacionada con web… no es una capa de dominio.
4) Infrastructure (Detalles)
Qué hace: implementación de detalles externos.
- repositorios de base de datos
- almacenamiento de archivos
- brokers de mensajerĂa
- clientes HTTP
Infrastructure depende de Application y Domain, nunca al revés.
Problemas que resuelve Clean Architecture
1) “Todo está acoplado con todo”
Cuando los controladores hablan directamente con el DbContext de EF, tu API queda casada con tu estrategia de persistencia. Cambiar la base de datos se vuelve aterrador.
Clean Architecture ayuda porque la API habla con un caso de uso, y el caso de uso habla con una abstracciĂłn.
2) El conocimiento queda atrapado en la infraestructura
Si tu “lĂłgica de negocio” vive dentro de queries SQL, stored procedures, controladores u ORMs, se vuelve más difĂcil de testear y más fácil de romper.
Clean Architecture te obliga a nombrar tus comportamientos (casos de uso) y mantenerlos en un solo lugar.
3) El testing se vuelve realista (y más rápido)
Los casos de uso se pueden testear unitariamente sin levantar el servidor web, la base de datos ni un entorno completo.
Eso no es “testear por diversión” — es reducir riesgo.
Malas prácticas comunes (y cómo los equipos arruinan la arquitectura sin darse cuenta)
1) Demasiadas capas que no hacen nada ❌
Un antipatrón clásico es el “teatro de Clean Architecture”:
CreateMovieHandlerCreateMovieServiceCreateMovieManagerCreateMovieProcessorCreateMovieCoordinator
…donde cada clase simplemente reenvĂa la llamada a la siguiente.
Si una capa no añade una responsabilidad clara, no es arquitectura — es ruido.
Regla de oro: Si puedes eliminar una capa y nada significativo cambia, esa capa no merecĂa existir.
2) Convertir cada petición en un mini framework ❌
No necesitas un framework para implementar Clean Architecture. Necesitas lĂmites claros.
Muchos equipos añaden MediatR (o similar) por defecto. El trade-off es real:
- âś… los pipelines y behaviors pueden ser Ăştiles
- ❌ el flujo se vuelve más difĂcil de seguir
- ❌ debuggear se siente indirecto
- ❌ los nuevos miembros del equipo tienen más carga mental
Puedes aplicar Clean Architecture con interfaces y clases simples — de forma clara y predecible.
El ejemplo: una API de pelĂculas (sin MediatR)
Vamos a construir una pequeña API de pelĂculas:
POST /api/movies→ crear una pelĂculaGET /api/movies/{id}→ obtener detalles
La mantendremos intencionalmente pequeña para que la estructura sea fácil de ver.
Estructura del proyecto
src/
├── Movies.Api
│ ├── Controllers
│ └── Program.cs
├── Movies.Application
│ ├── Abstractions
│ ├── UseCases
│ └── DTOs
├── Movies.Domain
│ └── Entities
└── Movies.Infrastructure
└── Repositories
Por qué esto importa: hace obvio dónde debe vivir cada pieza de código.
Capa de Domain: la entidad Movie
namespace Movies.Domain.Entities;
public class Movie
{
public Guid Id { get; private set; }
public string Title { get; private set; }
public Movie(string title)
{
Id = Guid.NewGuid();
Title = title;
}
}
Qué observar
- Sin tipos de ASP.NET.
- Sin atributos de EF Core.
- Sin preocupaciones de base de datos.
Esta entidad es válida sin importar si tu almacenamiento es SQL, NoSQL o un archivo plano.
El dominio es la parte que quieres mantener estable por años.
Capa de Application: definir el comportamiento
DTO (entrada)
namespace Movies.Application.DTOs;
public record CreateMovieRequest(string Title);
¿Por qué un DTO? Porque el contrato de tu API no es lo mismo que tu modelo de dominio. Un DTO es un objeto de frontera.
Contrato del repositorio
namespace Movies.Application.Abstractions;
using Movies.Domain.Entities;
public interface IMovieRepository
{
Task AddAsync(Movie movie);
Task<Movie?> GetByIdAsync(Guid id);
}
¿Por qué una interfaz aqu� Porque la capa de Application no debe saber cómo funciona la persistencia — solo qué necesita.
Esto hace que Infrastructure sea reemplazable y el testing más sencillo.
Caso de uso: CreateMovie
namespace Movies.Application.UseCases;
using Movies.Application.Abstractions;
using Movies.Application.DTOs;
using Movies.Domain.Entities;
public interface ICreateMovieUseCase
{
Task ExecuteAsync(CreateMovieRequest request);
}
public class CreateMovieUseCase : ICreateMovieUseCase
{
private readonly IMovieRepository _repository;
public CreateMovieUseCase(IMovieRepository repository)
{
_repository = repository;
}
public async Task ExecuteAsync(CreateMovieRequest request)
{
var movie = new Movie(request.Title);
await _repository.AddAsync(movie);
}
}
Qué observar
- El caso de uso se lee como una oraciĂłn: crea una pelĂcula, guárdala.
- El caso de uso es testeable con un repositorio falso.
- No hay lĂłgica HTTP aquĂ. Sin cĂłdigos de estado. Sin controladores.
Los casos de uso son donde viven los “verbos” de tu sistema.
Capa de Infrastructure: una implementaciĂłn simple del repositorio
namespace Movies.Infrastructure.Repositories;
using Movies.Application.Abstractions;
using Movies.Domain.Entities;
public class InMemoryMovieRepository : IMovieRepository
{
private readonly List<Movie> _movies = new();
public Task AddAsync(Movie movie)
{
_movies.Add(movie);
return Task.CompletedTask;
}
public Task<Movie?> GetByIdAsync(Guid id)
{
return Task.FromResult(_movies.FirstOrDefault(m => m.Id == id));
}
}
Qué observar
- Infrastructure implementa el contrato que la capa de Application pidiĂł.
- Cambiar esto por EF Core después está aislado en Infrastructure.
Esta es la capa de “detalles”. Los detalles pueden cambiar libremente.
Capa de API: los controladores deben ser delgados
namespace Movies.Api.Controllers;
using Microsoft.AspNetCore.Mvc;
using Movies.Application.DTOs;
using Movies.Application.UseCases;
[ApiController]
[Route("api/movies")]
public class MoviesController : ControllerBase
{
private readonly ICreateMovieUseCase _createMovie;
public MoviesController(ICreateMovieUseCase createMovie)
{
_createMovie = createMovie;
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateMovieRequest request)
{
await _createMovie.ExecuteAsync(request);
return StatusCode(201);
}
}
Qué observar
- El controlador coordina Ăşnicamente HTTP.
- Delega el trabajo real al caso de uso.
Esto hace que los controladores sean fáciles de mantener y refactorizar sin romper reglas de negocio.
Conectando todo (InyecciĂłn de Dependencias)
builder.Services.AddScoped<ICreateMovieUseCase, CreateMovieUseCase>();
builder.Services.AddScoped<IMovieRepository, InMemoryMovieRepository>();
Por quĂ© la DI explĂcita es buena aquĂ
Puedes “ver el sistema” en un solo lugar. Sin escaneo mágico necesario.
CQRS (y por qué encaja naturalmente con Clean Architecture)
CQRS significa separar:
- Commands (operaciones de escritura que cambian estado)
- Queries (operaciones de lectura que retornan datos)
No tienes que ir “full CQRS” para obtener beneficios. Incluso un enfoque simplificado ayuda:
- Los casos de uso de escritura se enfocan en correctitud y transacciones
- Los casos de uso de lectura se enfocan en queries rápidas y view models especĂficos
Esto es especialmente valioso cuando las lecturas son pesadas y las escrituras necesitan ser seguras.
Réplicas de lectura vs escrituras transaccionales (la ventaja en el mundo real)
En muchos sistemas de producciĂłn:
- Las escrituras van a una base de datos transaccional primaria (consistencia fuerte, locks, constraints).
- Las lecturas pueden ir a una réplica de lectura (escalada para carga de queries).
Por qué esto es útil
- Mantiene la BD primaria más saludable (las escrituras no pelean con lecturas pesadas).
- Mejora el rendimiento para endpoints con muchas lecturas.
- Permite escalar las lecturas horizontalmente.
El trade-off (seamos honestos)
Las rĂ©plicas de lectura pueden estar ligeramente atrasadas (lag de replicaciĂłn), asĂ que podrĂas no ver una escritura inmediatamente.
Por eso CQRS no es “gratis”. Es un intercambio.
Un enfoque práctico: usa rĂ©plicas para páginas con muchas lecturas donde “segundos de atraso” es aceptable, y enruta las lecturas crĂticas de consistencia a la primaria.
Caso de uso: GetMovieDetails
Ahora implementemos el lado de lectura. Primero, el DTO de respuesta:
namespace Movies.Application.DTOs;
public record MovieDetailsResponse(Guid Id, string Title);
El caso de uso:
namespace Movies.Application.UseCases;
using Movies.Application.Abstractions;
using Movies.Application.DTOs;
public interface IGetMovieDetailsUseCase
{
Task<MovieDetailsResponse?> ExecuteAsync(Guid id);
}
public class GetMovieDetailsUseCase : IGetMovieDetailsUseCase
{
private readonly IMovieRepository _repository;
public GetMovieDetailsUseCase(IMovieRepository repository)
{
_repository = repository;
}
public async Task<MovieDetailsResponse?> ExecuteAsync(Guid id)
{
var movie = await _repository.GetByIdAsync(id);
if (movie is null)
return null;
return new MovieDetailsResponse(movie.Id, movie.Title);
}
}
Y el endpoint del controlador:
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var movie = await _getMovieDetails.ExecuteAsync(id);
if (movie is null)
return NotFound();
return Ok(movie);
}
Qué observar
- El caso de uso retorna un DTO, no la entidad de dominio.
- El controlador maneja la semántica HTTP (404 para no encontrado).
- El caso de uso permanece testeable y reutilizable.
El patrĂłn Result (manejando errores sin excepciones)
Retornar null funciona para casos simples, pero ¿qué pasa con errores de validación, violaciones de reglas de negocio o diferentes modos de fallo?
El patrĂłn Result hace explĂcitos el Ă©xito y el fracaso:
namespace Movies.Application.Common;
public class Result<T>
{
public T? Value { get; }
public string? Error { get; }
public bool IsSuccess => Error is null;
public bool IsFailure => !IsSuccess;
private Result(T? value, string? error)
{
Value = value;
Error = error;
}
public static Result<T> Success(T value) => new(value, null);
public static Result<T> Failure(string error) => new(default, error);
}
Ahora los casos de uso pueden comunicar por qué algo falló:
public interface ICreateMovieUseCase
{
Task<Result<Guid>> ExecuteAsync(CreateMovieRequest request);
}
public class CreateMovieUseCase : ICreateMovieUseCase
{
private readonly IMovieRepository _repository;
public CreateMovieUseCase(IMovieRepository repository)
{
_repository = repository;
}
public async Task<Result<Guid>> ExecuteAsync(CreateMovieRequest request)
{
if (string.IsNullOrWhiteSpace(request.Title))
return Result<Guid>.Failure("El tĂtulo es requerido");
if (request.Title.Length > 200)
return Result<Guid>.Failure("El tĂtulo debe tener 200 caracteres o menos");
var movie = new Movie(request.Title);
await _repository.AddAsync(movie);
return Result<Guid>.Success(movie.Id);
}
}
El controlador traduce resultados a HTTP:
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateMovieRequest request)
{
var result = await _createMovie.ExecuteAsync(request);
if (result.IsFailure)
return BadRequest(new { error = result.Error });
return CreatedAtAction(nameof(GetById), new { id = result.Value }, null);
}
Por qué esto importa
- Sin excepciones ocultas para fallos esperados.
- La firma del caso de uso te dice que puede fallar.
- Los controladores se convierten en traductores simples.
- El testing es directo: verificar
IsSuccessoIsFailure.
ValidaciĂłn con FluentValidation
Para reglas de validaciĂłn complejas, las verificaciones inline se vuelven desordenadas. FluentValidation mantiene las reglas organizadas y reutilizables.
Primero, instala el paquete:
dotnet add package FluentValidation.DependencyInjectionExtensions
Define un validador:
namespace Movies.Application.Validators;
using FluentValidation;
using Movies.Application.DTOs;
public class CreateMovieRequestValidator : AbstractValidator<CreateMovieRequest>
{
public CreateMovieRequestValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("El tĂtulo es requerido")
.MaximumLength(200).WithMessage("El tĂtulo debe tener 200 caracteres o menos")
.Must(NoContenerCaracteresEspeciales).WithMessage("El tĂtulo contiene caracteres inválidos");
}
private bool NoContenerCaracteresEspeciales(string title)
{
return !title.Any(c => c == '<' || c == '>' || c == '&');
}
}
Intégralo con el caso de uso:
public class CreateMovieUseCase : ICreateMovieUseCase
{
private readonly IMovieRepository _repository;
private readonly IValidator<CreateMovieRequest> _validator;
public CreateMovieUseCase(
IMovieRepository repository,
IValidator<CreateMovieRequest> validator)
{
_repository = repository;
_validator = validator;
}
public async Task<Result<Guid>> ExecuteAsync(CreateMovieRequest request)
{
var validation = await _validator.ValidateAsync(request);
if (!validation.IsValid)
{
var errors = string.Join("; ", validation.Errors.Select(e => e.ErrorMessage));
return Result<Guid>.Failure(errors);
}
var movie = new Movie(request.Title);
await _repository.AddAsync(movie);
return Result<Guid>.Success(movie.Id);
}
}
Registra los validadores en DI:
builder.Services.AddValidatorsFromAssemblyContaining<CreateMovieRequestValidator>();
Qué observar
- Las reglas de validaciĂłn viven en un solo lugar.
- Las reglas son testeables de forma independiente.
- El caso de uso se mantiene enfocado en la orquestaciĂłn.
Testing unitario del caso de uso
AquĂ es donde Clean Architecture da sus frutos. El testing es simple porque las dependencias están abstraĂdas.
namespace Movies.Tests.UseCases;
using FluentValidation;
using FluentValidation.Results;
using Moq;
using Movies.Application.Abstractions;
using Movies.Application.DTOs;
using Movies.Application.UseCases;
using Movies.Domain.Entities;
public class CreateMovieUseCaseTests
{
private readonly Mock<IMovieRepository> _repositoryMock;
private readonly Mock<IValidator<CreateMovieRequest>> _validatorMock;
private readonly CreateMovieUseCase _useCase;
public CreateMovieUseCaseTests()
{
_repositoryMock = new Mock<IMovieRepository>();
_validatorMock = new Mock<IValidator<CreateMovieRequest>>();
_useCase = new CreateMovieUseCase(_repositoryMock.Object, _validatorMock.Object);
}
[Fact]
public async Task ExecuteAsync_ConRequestValido_RetornaExitoConId()
{
// Arrange
var request = new CreateMovieRequest("Inception");
_validatorMock
.Setup(v => v.ValidateAsync(request, default))
.ReturnsAsync(new ValidationResult());
// Act
var result = await _useCase.ExecuteAsync(request);
// Assert
Assert.True(result.IsSuccess);
Assert.NotEqual(Guid.Empty, result.Value);
_repositoryMock.Verify(r => r.AddAsync(It.IsAny<Movie>()), Times.Once);
}
[Fact]
public async Task ExecuteAsync_ConRequestInvalido_RetornaFallo()
{
// Arrange
var request = new CreateMovieRequest("");
var validationFailure = new ValidationFailure("Title", "El tĂtulo es requerido");
_validatorMock
.Setup(v => v.ValidateAsync(request, default))
.ReturnsAsync(new ValidationResult(new[] { validationFailure }));
// Act
var result = await _useCase.ExecuteAsync(request);
// Assert
Assert.True(result.IsFailure);
Assert.Contains("El tĂtulo es requerido", result.Error);
_repositoryMock.Verify(r => r.AddAsync(It.IsAny<Movie>()), Times.Never);
}
[Fact]
public async Task ExecuteAsync_LlamaAlRepositorioConTituloCorrecto()
{
// Arrange
var request = new CreateMovieRequest("The Matrix");
Movie? capturedMovie = null;
_validatorMock
.Setup(v => v.ValidateAsync(request, default))
.ReturnsAsync(new ValidationResult());
_repositoryMock
.Setup(r => r.AddAsync(It.IsAny<Movie>()))
.Callback<Movie>(m => capturedMovie = m);
// Act
await _useCase.ExecuteAsync(request);
// Assert
Assert.NotNull(capturedMovie);
Assert.Equal("The Matrix", capturedMovie.Title);
}
}
Qué observar
- Sin base de datos, sin servidor HTTP, sin dependencias externas.
- Los tests corren rápido (milisegundos).
- Cada test se enfoca en un comportamiento.
- Los mocks verifican interacciones, no detalles de implementaciĂłn.
HATEOAS (Hypermedia as the Engine of Application State)
HATEOAS hace que tu API sea autodescriptiva al incluir enlaces a acciones relacionadas.
Primero, crea un modelo de enlace:
namespace Movies.Application.DTOs;
public record Link(string Href, string Rel, string Method);
public record MovieDetailsResponse(
Guid Id,
string Title,
List<Link> Links
);
Construye los enlaces en el controlador (donde conoces las rutas):
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var movie = await _getMovieDetails.ExecuteAsync(id);
if (movie is null)
return NotFound();
var response = new MovieDetailsResponse(
movie.Id,
movie.Title,
Links: BuildLinks(movie.Id)
);
return Ok(response);
}
private List<Link> BuildLinks(Guid movieId)
{
return new List<Link>
{
new(Url.Action(nameof(GetById), new { id = movieId })!, "self", "GET"),
new(Url.Action(nameof(Update), new { id = movieId })!, "update", "PUT"),
new(Url.Action(nameof(Delete), new { id = movieId })!, "delete", "DELETE"),
new(Url.Action(nameof(GetAll))!, "all-movies", "GET")
};
}
La respuesta ahora incluye navegaciĂłn:
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"title": "Inception",
"links": [
{
"href": "/api/movies/3fa85f64-5717-4562-b3fc-2c963f66afa6",
"rel": "self",
"method": "GET"
},
{
"href": "/api/movies/3fa85f64-5717-4562-b3fc-2c963f66afa6",
"rel": "update",
"method": "PUT"
},
{
"href": "/api/movies/3fa85f64-5717-4562-b3fc-2c963f66afa6",
"rel": "delete",
"method": "DELETE"
},
{ "href": "/api/movies", "rel": "all-movies", "method": "GET" }
]
}
Por qué HATEOAS importa
- Los clientes descubren acciones disponibles dinámicamente.
- Los cambios en la API no rompen clientes que siguen los enlaces.
- La documentación está embebida en las respuestas.
El trade-off
- Respuestas más verbosas.
- No todos los clientes necesitan o usan esto.
- ConsidĂ©ralo para APIs pĂşblicas; omĂtelo para APIs internas.
Versionado de API
Las APIs evolucionan. El versionado te permite introducir cambios incompatibles sin romper clientes existentes.
Instala el paquete:
dotnet add package Asp.Versioning.Mvc
Configura en Program.cs:
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("X-Api-Version")
);
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
Versiona tus controladores:
namespace Movies.Api.Controllers.V1;
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/movies")]
public class MoviesController : ControllerBase
{
// ImplementaciĂłn V1
}
Crea V2 con cambios incompatibles:
namespace Movies.Api.Controllers.V2;
[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/movies")]
public class MoviesController : ControllerBase
{
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
// V2 retorna una forma de respuesta diferente
var movie = await _getMovieDetails.ExecuteAsync(id);
if (movie is null)
return NotFound();
// V2 incluye rating y año de estreno
return Ok(new
{
movie.Id,
movie.Title,
Rating = "PG-13", // Nuevo en V2
ReleaseYear = 2010 // Nuevo en V2
});
}
}
Los clientes ahora pueden llamar:
GET /api/v1/movies/{id}→ respuesta originalGET /api/v2/movies/{id}→ respuesta mejorada
O usar headers:
GET /api/movies/{id}
X-Api-Version: 2.0
Estrategias de versionado
| Estrategia | Ejemplo | Ventajas | Desventajas |
|---|---|---|---|
| Ruta URL | /api/v1/movies | ExplĂcito, fácil de testear | Las URLs cambian |
| Header | X-Api-Version: 1 | URLs limpias | Menos descubrible |
| Query string | ?api-version=1 | Fácil de agregar | Ensucia las URLs |
RecomendaciĂłn: Usa ruta URL para APIs pĂşblicas (explĂcito y cacheable), headers para APIs internas.
Reflexiones finales: Clean Architecture es criterio, no ceremonia
Si tu arquitectura:
- oculta el flujo,
- añade capas sin propósito,
- o asusta a tu equipo…
…no es limpia.
Clean Architecture debe hacer que el sistema sea:
- más fácil de entender
- más fácil de testear
- más fácil de cambiar
No necesitas un framework para lograrlo. Necesitas lĂmites claros y buen criterio de ingenierĂa.