# Clean Architecture con .NET: Guía Práctica > Domina Clean Architecture en .NET con C#. Aprende los principios SOLID, la separación de capas y los patrones de implementación para aplicaciones mantenibles. - Published: 2026-02-13 - Updated: 2026-04-28 - Author: SharpSkill - Tags: dotnet, clean architecture, csharp, aspnet core, design patterns - Reading time: 16 min --- Clean Architecture, popularizada por Robert C. Martin (Uncle Bob), organiza el código colocando la lógica de negocio en el centro de la aplicación, independiente de los frameworks y los detalles de implementación. Este enfoque arquitectónico garantiza la testabilidad, mantenibilidad y escalabilidad de las aplicaciones .NET. Esta guía presenta una implementación práctica con ASP.NET Core. > **¿Por qué Clean Architecture?** > > Las aplicaciones que mezclan lógica de negocio con código de infraestructura se vuelven rápidamente difíciles de mantener. Clean Architecture impone una separación estricta que permite modificar los detalles técnicos sin impactar el núcleo del negocio. ## Principios fundamentales de Clean Architecture Clean Architecture se basa en la inversión de dependencias: las capas internas no conocen las capas externas. El dominio de negocio permanece aislado y puede evolucionar independientemente de las decisiones técnicas como el framework web o la base de datos. ``` ┌─────────────────────────────────────────────────────────┐ │ Presentation │ │ (Controllers, Razor Pages, Blazor, API Endpoints) │ ├─────────────────────────────────────────────────────────┤ │ Infrastructure │ │ (EF Core, External APIs, File System, Email) │ ├─────────────────────────────────────────────────────────┤ │ Application │ │ (Use Cases, Commands, Queries, DTOs) │ ├─────────────────────────────────────────────────────────┤ │ Domain │ │ (Entities, Value Objects, Domain Services) │ └─────────────────────────────────────────────────────────┘ ``` Las dependencias siempre apuntan hacia adentro: Presentation → Infrastructure → Application → Domain. El Domain no referencia ningún otro proyecto. ## Estructura del proyecto .NET en Clean Architecture La organización de los proyectos refleja las diferentes capas. Cada capa corresponde a un proyecto distinto en la solución de Visual Studio, garantizando una separación física de las responsabilidades. ```bash # terminal # Crear estructura de la solución dotnet new sln -n CleanArchitecture # Crear proyectos por capa dotnet new classlib -n CleanArchitecture.Domain -o src/CleanArchitecture.Domain dotnet new classlib -n CleanArchitecture.Application -o src/CleanArchitecture.Application dotnet new classlib -n CleanArchitecture.Infrastructure -o src/CleanArchitecture.Infrastructure dotnet new webapi -n CleanArchitecture.Api -o src/CleanArchitecture.Api # Añadir proyectos a la solución dotnet sln add src/CleanArchitecture.Domain dotnet sln add src/CleanArchitecture.Application dotnet sln add src/CleanArchitecture.Infrastructure dotnet sln add src/CleanArchitecture.Api # Configurar referencias entre proyectos cd src/CleanArchitecture.Application dotnet add reference ../CleanArchitecture.Domain cd ../CleanArchitecture.Infrastructure dotnet add reference ../CleanArchitecture.Application cd ../CleanArchitecture.Api dotnet add reference ../CleanArchitecture.Infrastructure ``` Esta estructura garantiza que el Domain permanezca independiente y que las dependencias respeten la regla de dependencia (Dependency Rule) de Clean Architecture. ## La capa Domain: el núcleo del negocio La capa Domain contiene las entidades de negocio, los Value Objects y las interfaces de los repositorios. No se permite ninguna dependencia externa en esta capa. ```csharp // Domain/Entities/Order.cs namespace CleanArchitecture.Domain.Entities; // Entidad con identidad y ciclo de vida de negocio public class Order { // Identificador único del pedido public Guid Id { get; private set; } // Referencia al cliente (Value Object para el email) public string CustomerEmail { get; private set; } // Colección de items (relación uno a muchos) private readonly List _items = new(); public IReadOnlyCollection Items => _items.AsReadOnly(); // Estado del pedido (enum de negocio) public OrderStatus Status { get; private set; } // Monto total calculado public decimal TotalAmount => _items.Sum(i => i.TotalPrice); // Fechas de seguimiento public DateTime CreatedAt { get; private set; } public DateTime? ShippedAt { get; private set; } // Constructor privado para forzar el uso del factory method private Order() { } // Factory method para crear un pedido válido public static Order Create(string customerEmail) { // Validación de reglas de negocio en la creación if (string.IsNullOrWhiteSpace(customerEmail)) throw new DomainException("El email del cliente es obligatorio."); if (!IsValidEmail(customerEmail)) throw new DomainException("Formato de email inválido."); return new Order { Id = Guid.NewGuid(), CustomerEmail = customerEmail, Status = OrderStatus.Pending, CreatedAt = DateTime.UtcNow }; } // Método de negocio: añadir un item public void AddItem(Product product, int quantity) { // Regla de negocio: no se puede modificar un pedido enviado if (Status == OrderStatus.Shipped) throw new DomainException("No se puede modificar un pedido ya enviado."); if (quantity <= 0) throw new DomainException("La cantidad debe ser positiva."); // Verificar si el producto ya existe var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id); if (existingItem != null) { existingItem.IncreaseQuantity(quantity); } else { _items.Add(OrderItem.Create(this, product, quantity)); } } // Método de negocio: confirmar el pedido public void Confirm() { if (Status != OrderStatus.Pending) throw new DomainException("Solo los pedidos pendientes pueden confirmarse."); if (!_items.Any()) throw new DomainException("Un pedido debe contener al menos un item."); Status = OrderStatus.Confirmed; } // Método de negocio: enviar el pedido public void Ship() { if (Status != OrderStatus.Confirmed) throw new DomainException("El pedido debe ser confirmado antes de enviarlo."); Status = OrderStatus.Shipped; ShippedAt = DateTime.UtcNow; } private static bool IsValidEmail(string email) => email.Contains('@') && email.Contains('.'); } ``` La entidad Order encapsula las reglas de negocio y protege su estado interno. Las modificaciones deben pasar por métodos de negocio que validan los invariantes. ```csharp // Domain/Entities/OrderItem.cs namespace CleanArchitecture.Domain.Entities; // Entidad hija con identidad propia public class OrderItem { public Guid Id { get; private set; } public Guid OrderId { get; private set; } public Guid ProductId { get; private set; } public string ProductName { get; private set; } public decimal UnitPrice { get; private set; } public int Quantity { get; private set; } // Cálculo del precio total de la línea public decimal TotalPrice => UnitPrice * Quantity; private OrderItem() { } // Factory method con validación public static OrderItem Create(Order order, Product product, int quantity) { return new OrderItem { Id = Guid.NewGuid(), OrderId = order.Id, ProductId = product.Id, ProductName = product.Name, UnitPrice = product.Price, Quantity = quantity }; } // Método para aumentar la cantidad public void IncreaseQuantity(int additionalQuantity) { if (additionalQuantity <= 0) throw new DomainException("La cantidad adicional debe ser positiva."); Quantity += additionalQuantity; } } ``` ```csharp // Domain/Enums/OrderStatus.cs namespace CleanArchitecture.Domain.Enums; // Enumeración de los estados del pedido public enum OrderStatus { Pending = 0, // En espera de confirmación Confirmed = 1, // Confirmado, listo para envío Shipped = 2, // Enviado Delivered = 3, // Entregado Cancelled = 4 // Cancelado } ``` > **Value Objects** > > Los Value Objects como Money, Address o Email encapsulan conceptos de negocio sin identidad propia. Su igualdad se basa en sus valores, no en sus referencias. Este enfoque refuerza la expresividad del dominio. ## Las interfaces Repository en el Domain Las interfaces de los repositorios se definen en el Domain, pero su implementación reside en Infrastructure. Este patrón respeta el principio de inversión de dependencias. ```csharp // Domain/Interfaces/IOrderRepository.cs namespace CleanArchitecture.Domain.Interfaces; // Interfaz del repositorio Order public interface IOrderRepository { // Recuperación por identificador Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); // Recuperación con items incluidos Task GetByIdWithItemsAsync(Guid id, CancellationToken cancellationToken = default); // Recuperación por email del cliente Task> GetByCustomerEmailAsync( string email, CancellationToken cancellationToken = default); // Añadir un nuevo pedido Task AddAsync(Order order, CancellationToken cancellationToken = default); // Actualizar un pedido existente Task UpdateAsync(Order order, CancellationToken cancellationToken = default); // Eliminar un pedido Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); } ``` ```csharp // Domain/Interfaces/IUnitOfWork.cs namespace CleanArchitecture.Domain.Interfaces; // Patrón Unit of Work para la gestión transaccional public interface IUnitOfWork : IDisposable { // Repositorios accesibles vía UoW IOrderRepository Orders { get; } IProductRepository Products { get; } // Guardado atómico de todos los cambios Task SaveChangesAsync(CancellationToken cancellationToken = default); // Gestión explícita de transacciones Task BeginTransactionAsync(CancellationToken cancellationToken = default); Task CommitTransactionAsync(CancellationToken cancellationToken = default); Task RollbackTransactionAsync(CancellationToken cancellationToken = default); } ``` El patrón Unit of Work coordina las operaciones de varios repositorios dentro de una misma transacción. ## La capa Application: orquestación de Use Cases La capa Application contiene la lógica aplicativa (use cases), los DTOs y las interfaces de los servicios externos. Orquesta las interacciones entre el dominio y el mundo exterior. ```csharp // Application/Orders/Commands/CreateOrderCommand.cs using MediatR; namespace CleanArchitecture.Application.Orders.Commands; // Command que representa la intención de crear un pedido public record CreateOrderCommand( string CustomerEmail, List Items ) : IRequest; // DTO para los items del pedido public record OrderItemDto( Guid ProductId, int Quantity ); ``` ```csharp // Application/Orders/Commands/CreateOrderCommandHandler.cs using MediatR; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Interfaces; namespace CleanArchitecture.Application.Orders.Commands; // Handler que implementa el caso de uso public class CreateOrderCommandHandler : IRequestHandler { private readonly IUnitOfWork _unitOfWork; public CreateOrderCommandHandler(IUnitOfWork unitOfWork) { _unitOfWork = unitOfWork; } public async Task Handle( CreateOrderCommand request, CancellationToken cancellationToken) { // Crear la entidad Order vía factory method var order = Order.Create(request.CustomerEmail); // Añadir los items al pedido foreach (var item in request.Items) { // Recuperar el producto del repositorio var product = await _unitOfWork.Products .GetByIdAsync(item.ProductId, cancellationToken); if (product == null) throw new NotFoundException($"Producto {item.ProductId} no encontrado."); // Usar el método de negocio de la entidad order.AddItem(product, item.Quantity); } // Persistir vía repositorio await _unitOfWork.Orders.AddAsync(order, cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken); return order.Id; } } ``` MediatR implementa el patrón Mediator para desacoplar los handlers de los controladores. Cada command tiene un único handler responsable de su procesamiento. ```csharp // Application/Orders/Queries/GetOrderByIdQuery.cs using MediatR; namespace CleanArchitecture.Application.Orders.Queries; // Query para recuperar un pedido por ID public record GetOrderByIdQuery(Guid OrderId) : IRequest; // DTO de respuesta para un pedido public record OrderDto( Guid Id, string CustomerEmail, string Status, decimal TotalAmount, DateTime CreatedAt, DateTime? ShippedAt, List Items ); // DTO para los items en la respuesta public record OrderItemResponseDto( Guid Id, string ProductName, decimal UnitPrice, int Quantity, decimal TotalPrice ); ``` ```csharp // Application/Orders/Queries/GetOrderByIdQueryHandler.cs using MediatR; using CleanArchitecture.Domain.Interfaces; namespace CleanArchitecture.Application.Orders.Queries; public class GetOrderByIdQueryHandler : IRequestHandler { private readonly IOrderRepository _orderRepository; public GetOrderByIdQueryHandler(IOrderRepository orderRepository) { _orderRepository = orderRepository; } public async Task Handle( GetOrderByIdQuery request, CancellationToken cancellationToken) { // Recuperar con items incluidos var order = await _orderRepository .GetByIdWithItemsAsync(request.OrderId, cancellationToken); if (order == null) return null; // Mapeo a DTO (proyección manual) return new OrderDto( Id: order.Id, CustomerEmail: order.CustomerEmail, Status: order.Status.ToString(), TotalAmount: order.TotalAmount, CreatedAt: order.CreatedAt, ShippedAt: order.ShippedAt, Items: order.Items.Select(i => new OrderItemResponseDto( Id: i.Id, ProductName: i.ProductName, UnitPrice: i.UnitPrice, Quantity: i.Quantity, TotalPrice: i.TotalPrice )).ToList() ); } } ``` La separación Commands/Queries sigue el patrón CQRS (Command Query Responsibility Segregation), optimizando lecturas y escrituras de forma independiente. ## Validación con FluentValidation La validación de los commands se realiza en la capa Application, antes de la ejecución del handler. ```csharp // Application/Orders/Validators/CreateOrderCommandValidator.cs using FluentValidation; using CleanArchitecture.Application.Orders.Commands; namespace CleanArchitecture.Application.Orders.Validators; public class CreateOrderCommandValidator : AbstractValidator { public CreateOrderCommandValidator() { // Email obligatorio y formato válido RuleFor(x => x.CustomerEmail) .NotEmpty().WithMessage("El email del cliente es obligatorio.") .EmailAddress().WithMessage("Formato de email inválido."); // Al menos un item obligatorio RuleFor(x => x.Items) .NotEmpty().WithMessage("El pedido debe contener al menos un item."); // Validación de cada item RuleForEach(x => x.Items).ChildRules(item => { item.RuleFor(i => i.ProductId) .NotEmpty().WithMessage("El identificador del producto es obligatorio."); item.RuleFor(i => i.Quantity) .GreaterThan(0).WithMessage("La cantidad debe ser positiva.") .LessThanOrEqualTo(100).WithMessage("Cantidad máxima: 100."); }); } } ``` ```csharp // Application/Common/Behaviors/ValidationBehavior.cs using FluentValidation; using MediatR; namespace CleanArchitecture.Application.Common.Behaviors; // Pipeline behavior para validación automática public class ValidationBehavior : IPipelineBehavior where TRequest : notnull { private readonly IEnumerable> _validators; public ValidationBehavior(IEnumerable> validators) { _validators = validators; } public async Task Handle( TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { // Si no hay validadores, continuar if (!_validators.Any()) return await next(); // Ejecutar todos los validadores var context = new ValidationContext(request); var validationResults = await Task.WhenAll( _validators.Select(v => v.ValidateAsync(context, cancellationToken))); // Agregar errores var failures = validationResults .SelectMany(r => r.Errors) .Where(f => f != null) .ToList(); // Lanzar excepción si hay errores if (failures.Any()) throw new ValidationException(failures); return await next(); } } ``` El ValidationBehavior se ejecuta automáticamente antes de cada handler, garantizando que solo los commands válidos lleguen a la lógica de negocio. ## La capa Infrastructure: implementación técnica La capa Infrastructure proporciona las implementaciones concretas de las interfaces definidas en Domain y Application. ```csharp // Infrastructure/Persistence/AppDbContext.cs using Microsoft.EntityFrameworkCore; using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.Infrastructure.Persistence; public class AppDbContext : DbContext { public AppDbContext(DbContextOptions options) : base(options) { } public DbSet Orders => Set(); public DbSet OrderItems => Set(); public DbSet Products => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { // Aplicar configuraciones desde el assembly modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); } } ``` ```csharp // Infrastructure/Persistence/Configurations/OrderConfiguration.cs using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.Infrastructure.Persistence.Configurations; public class OrderConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { // Tabla y clave primaria builder.ToTable("Orders"); builder.HasKey(o => o.Id); // Propiedades builder.Property(o => o.CustomerEmail) .IsRequired() .HasMaxLength(256); builder.Property(o => o.Status) .IsRequired() .HasConversion(); // Almacenar como string legible // Relación con OrderItems builder.HasMany(o => o.Items) .WithOne() .HasForeignKey(i => i.OrderId) .OnDelete(DeleteBehavior.Cascade); // Índice para búsqueda por email builder.HasIndex(o => o.CustomerEmail); // Acceder al campo privado _items builder.Navigation(o => o.Items) .UsePropertyAccessMode(PropertyAccessMode.Field); } } ``` ```csharp // Infrastructure/Repositories/OrderRepository.cs using Microsoft.EntityFrameworkCore; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Infrastructure.Persistence; namespace CleanArchitecture.Infrastructure.Repositories; public class OrderRepository : IOrderRepository { private readonly AppDbContext _context; public OrderRepository(AppDbContext context) { _context = context; } public async Task GetByIdAsync( Guid id, CancellationToken cancellationToken = default) { return await _context.Orders .FirstOrDefaultAsync(o => o.Id == id, cancellationToken); } public async Task GetByIdWithItemsAsync( Guid id, CancellationToken cancellationToken = default) { // Carga eager de los items return await _context.Orders .Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == id, cancellationToken); } public async Task> GetByCustomerEmailAsync( string email, CancellationToken cancellationToken = default) { return await _context.Orders .Include(o => o.Items) .Where(o => o.CustomerEmail == email) .OrderByDescending(o => o.CreatedAt) .ToListAsync(cancellationToken); } public async Task AddAsync( Order order, CancellationToken cancellationToken = default) { await _context.Orders.AddAsync(order, cancellationToken); } public Task UpdateAsync( Order order, CancellationToken cancellationToken = default) { // EF Core rastrea automáticamente los cambios _context.Orders.Update(order); return Task.CompletedTask; } public async Task DeleteAsync( Guid id, CancellationToken cancellationToken = default) { await _context.Orders .Where(o => o.Id == id) .ExecuteDeleteAsync(cancellationToken); } } ``` > **Cuidado con las Leaky Abstractions** > > Los repositorios no deben exponer IQueryable directamente, ya que esto crearía una dependencia hacia EF Core en las capas superiores. Es preferible utilizar métodos específicos con parámetros claros. ## Implementación de Unit of Work ```csharp // Infrastructure/Persistence/UnitOfWork.cs using Microsoft.EntityFrameworkCore.Storage; using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Infrastructure.Repositories; namespace CleanArchitecture.Infrastructure.Persistence; public class UnitOfWork : IUnitOfWork { private readonly AppDbContext _context; private IDbContextTransaction? _transaction; // Carga lazy de los repositorios private IOrderRepository? _orderRepository; private IProductRepository? _productRepository; public UnitOfWork(AppDbContext context) { _context = context; } // Creación bajo demanda (lazy initialization) public IOrderRepository Orders => _orderRepository ??= new OrderRepository(_context); public IProductRepository Products => _productRepository ??= new ProductRepository(_context); public async Task SaveChangesAsync( CancellationToken cancellationToken = default) { return await _context.SaveChangesAsync(cancellationToken); } public async Task BeginTransactionAsync( CancellationToken cancellationToken = default) { _transaction = await _context.Database .BeginTransactionAsync(cancellationToken); } public async Task CommitTransactionAsync( CancellationToken cancellationToken = default) { if (_transaction == null) throw new InvalidOperationException("No hay transacción activa."); await _transaction.CommitAsync(cancellationToken); await _transaction.DisposeAsync(); _transaction = null; } public async Task RollbackTransactionAsync( CancellationToken cancellationToken = default) { if (_transaction == null) throw new InvalidOperationException("No hay transacción activa."); await _transaction.RollbackAsync(cancellationToken); await _transaction.DisposeAsync(); _transaction = null; } public void Dispose() { _transaction?.Dispose(); _context.Dispose(); } } ``` ## Configuración de inyección de dependencias El registro de servicios se realiza en cada capa mediante métodos de extensión, luego orquestados en Program.cs. ```csharp // Infrastructure/DependencyInjection.cs using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Infrastructure.Persistence; using CleanArchitecture.Infrastructure.Repositories; namespace CleanArchitecture.Infrastructure; public static class DependencyInjection { public static IServiceCollection AddInfrastructure( this IServiceCollection services, IConfiguration configuration) { // Configuración de Entity Framework services.AddDbContext(options => options.UseSqlServer( configuration.GetConnectionString("DefaultConnection"), b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName))); // Registrar Unit of Work (Scoped) services.AddScoped(); // Repositorios individuales si es necesario services.AddScoped(); services.AddScoped(); return services; } } ``` ```csharp // Application/DependencyInjection.cs using FluentValidation; using MediatR; using Microsoft.Extensions.DependencyInjection; using CleanArchitecture.Application.Common.Behaviors; namespace CleanArchitecture.Application; public static class DependencyInjection { public static IServiceCollection AddApplication(this IServiceCollection services) { var assembly = typeof(DependencyInjection).Assembly; // Registrar MediatR con los handlers services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly)); // Registrar los validadores de FluentValidation services.AddValidatorsFromAssembly(assembly); // Pipeline behaviors (el orden de ejecución importa) services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); return services; } } ``` ```csharp // Api/Program.cs using CleanArchitecture.Application; using CleanArchitecture.Infrastructure; var builder = WebApplication.CreateBuilder(args); // Registrar las capas builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); // Configuración de la API builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run(); ``` ## La capa Presentation: controladores API Los controladores son adaptadores simples que delegan el trabajo a MediatR. ```csharp // Api/Controllers/OrdersController.cs using MediatR; using Microsoft.AspNetCore.Mvc; using CleanArchitecture.Application.Orders.Commands; using CleanArchitecture.Application.Orders.Queries; namespace CleanArchitecture.Api.Controllers; [ApiController] [Route("api/[controller]")] public class OrdersController : ControllerBase { private readonly IMediator _mediator; public OrdersController(IMediator mediator) { _mediator = mediator; } [HttpPost] [ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task Create( [FromBody] CreateOrderCommand command, CancellationToken cancellationToken) { // Delegación completa a MediatR var orderId = await _mediator.Send(command, cancellationToken); return CreatedAtAction( nameof(GetById), new { id = orderId }, orderId); } [HttpGet("{id:guid}")] [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetById( Guid id, CancellationToken cancellationToken) { var order = await _mediator.Send( new GetOrderByIdQuery(id), cancellationToken); if (order == null) return NotFound(); return Ok(order); } [HttpPost("{id:guid}/confirm")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Confirm( Guid id, CancellationToken cancellationToken) { await _mediator.Send(new ConfirmOrderCommand(id), cancellationToken); return NoContent(); } [HttpPost("{id:guid}/ship")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Ship( Guid id, CancellationToken cancellationToken) { await _mediator.Send(new ShipOrderCommand(id), cancellationToken); return NoContent(); } } ``` Los controladores permanecen ligeros y no contienen lógica de negocio, solo el mapeo HTTP. ## Tests unitarios de la capa Application Clean Architecture facilita los tests unitarios mediante el aislamiento de dependencias. ```csharp // Tests/Application/CreateOrderCommandHandlerTests.cs using Moq; using Xunit; using CleanArchitecture.Application.Orders.Commands; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Interfaces; namespace CleanArchitecture.Tests.Application; public class CreateOrderCommandHandlerTests { private readonly Mock _unitOfWorkMock; private readonly CreateOrderCommandHandler _handler; public CreateOrderCommandHandlerTests() { _unitOfWorkMock = new Mock(); _handler = new CreateOrderCommandHandler(_unitOfWorkMock.Object); } [Fact] public async Task Handle_ValidCommand_CreatesOrderAndReturnsId() { // Arrange var product = new Product { Id = Guid.NewGuid(), Name = "Test", Price = 100 }; _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync( It.IsAny(), It.IsAny())) .ReturnsAsync(product); _unitOfWorkMock.Setup(x => x.Orders.AddAsync( It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); _unitOfWorkMock.Setup(x => x.SaveChangesAsync(It.IsAny())) .ReturnsAsync(1); var command = new CreateOrderCommand( CustomerEmail: "test@example.com", Items: new List { new(ProductId: product.Id, Quantity: 2) }); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert Assert.NotEqual(Guid.Empty, result); _unitOfWorkMock.Verify(x => x.Orders.AddAsync( It.Is(o => o.CustomerEmail == "test@example.com"), It.IsAny()), Times.Once); _unitOfWorkMock.Verify(x => x.SaveChangesAsync( It.IsAny()), Times.Once); } [Fact] public async Task Handle_ProductNotFound_ThrowsNotFoundException() { // Arrange _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync( It.IsAny(), It.IsAny())) .ReturnsAsync((Product?)null); var command = new CreateOrderCommand( CustomerEmail: "test@example.com", Items: new List { new(ProductId: Guid.NewGuid(), Quantity: 1) }); // Act & Assert await Assert.ThrowsAsync( () => _handler.Handle(command, CancellationToken.None)); } } ``` Los mocks de Moq permiten aislar el handler de sus dependencias, probando únicamente la lógica de orquestación. ## Conclusión Clean Architecture con .NET proporciona una estructura robusta para aplicaciones empresariales. La separación estricta de responsabilidades entre las capas Domain, Application, Infrastructure y Presentation garantiza un código mantenible y testeable a largo plazo. ### Checklist Clean Architecture .NET - ✅ Domain aislado sin dependencias externas - ✅ Entidades con lógica de negocio encapsulada - ✅ Interfaces de repositorios en el Domain - ✅ Use Cases vía Commands/Queries (CQRS) - ✅ Validación con FluentValidation y Pipeline Behaviors - ✅ Infrastructure implementa las interfaces del Domain - ✅ Controladores ligeros que delegan a MediatR - ✅ Tests unitarios aislados con mocks La inversión inicial en esta arquitectura se rentabiliza rápidamente en proyectos medianos y grandes, donde los cambios de requisitos técnicos (migración de base de datos, cambio de framework) solo impactan las capas externas, preservando intacto el núcleo del negocio. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/es/blog/dotnet/clean-architecture-dotnet-practical-guide