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.

Guía Clean Architecture con .NET y C#

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.

text
┌─────────────────────────────────────────────────────────┐
│                     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.

Domain/Entities/Order.cscsharp
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<OrderItem> _items = new();
    public IReadOnlyCollection<OrderItem> 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.

Domain/Entities/OrderItem.cscsharp
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;
    }
}
Domain/Enums/OrderStatus.cscsharp
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.

Domain/Interfaces/IOrderRepository.cscsharp
namespace CleanArchitecture.Domain.Interfaces;

// Interfaz del repositorio Order
public interface IOrderRepository
{
    // Recuperación por identificador
    Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);

    // Recuperación con items incluidos
    Task<Order?> GetByIdWithItemsAsync(Guid id, CancellationToken cancellationToken = default);

    // Recuperación por email del cliente
    Task<IEnumerable<Order>> 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);
}
Domain/Interfaces/IUnitOfWork.cscsharp
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<int> 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.

¿Listo para aprobar tus entrevistas de .NET?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

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.

Application/Orders/Commands/CreateOrderCommand.cscsharp
using MediatR;

namespace CleanArchitecture.Application.Orders.Commands;

// Command que representa la intención de crear un pedido
public record CreateOrderCommand(
    string CustomerEmail,
    List<OrderItemDto> Items
) : IRequest<Guid>;

// DTO para los items del pedido
public record OrderItemDto(
    Guid ProductId,
    int Quantity
);
Application/Orders/Commands/CreateOrderCommandHandler.cscsharp
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<CreateOrderCommand, Guid>
{
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderCommandHandler(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }

    public async Task<Guid> 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.

Application/Orders/Queries/GetOrderByIdQuery.cscsharp
using MediatR;

namespace CleanArchitecture.Application.Orders.Queries;

// Query para recuperar un pedido por ID
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;

// DTO de respuesta para un pedido
public record OrderDto(
    Guid Id,
    string CustomerEmail,
    string Status,
    decimal TotalAmount,
    DateTime CreatedAt,
    DateTime? ShippedAt,
    List<OrderItemResponseDto> Items
);

// DTO para los items en la respuesta
public record OrderItemResponseDto(
    Guid Id,
    string ProductName,
    decimal UnitPrice,
    int Quantity,
    decimal TotalPrice
);
Application/Orders/Queries/GetOrderByIdQueryHandler.cscsharp
using MediatR;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Application.Orders.Queries;

public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
    private readonly IOrderRepository _orderRepository;

    public GetOrderByIdQueryHandler(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task<OrderDto?> 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.

Application/Orders/Validators/CreateOrderCommandValidator.cscsharp
using FluentValidation;
using CleanArchitecture.Application.Orders.Commands;

namespace CleanArchitecture.Application.Orders.Validators;

public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    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.");
        });
    }
}
Application/Common/Behaviors/ValidationBehavior.cscsharp
using FluentValidation;
using MediatR;

namespace CleanArchitecture.Application.Common.Behaviors;

// Pipeline behavior para validación automática
public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        // Si no hay validadores, continuar
        if (!_validators.Any())
            return await next();

        // Ejecutar todos los validadores
        var context = new ValidationContext<TRequest>(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.

Infrastructure/Persistence/AppDbContext.cscsharp
using Microsoft.EntityFrameworkCore;
using CleanArchitecture.Domain.Entities;

namespace CleanArchitecture.Infrastructure.Persistence;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }

    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Aplicar configuraciones desde el assembly
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}
Infrastructure/Persistence/Configurations/OrderConfiguration.cscsharp
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using CleanArchitecture.Domain.Entities;

namespace CleanArchitecture.Infrastructure.Persistence.Configurations;

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> 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<string>();  // 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);
    }
}
Infrastructure/Repositories/OrderRepository.cscsharp
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<Order?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task<Order?> 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<IEnumerable<Order>> 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

Infrastructure/Persistence/UnitOfWork.cscsharp
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<int> 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.

Infrastructure/DependencyInjection.cscsharp
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<AppDbContext>(options =>
            options.UseSqlServer(
                configuration.GetConnectionString("DefaultConnection"),
                b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));

        // Registrar Unit of Work (Scoped)
        services.AddScoped<IUnitOfWork, UnitOfWork>();

        // Repositorios individuales si es necesario
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<IProductRepository, ProductRepository>();

        return services;
    }
}
Application/DependencyInjection.cscsharp
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;
    }
}
Api/Program.cscsharp
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.

Api/Controllers/OrdersController.cscsharp
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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.

Tests/Application/CreateOrderCommandHandlerTests.cscsharp
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<IUnitOfWork> _unitOfWorkMock;
    private readonly CreateOrderCommandHandler _handler;

    public CreateOrderCommandHandlerTests()
    {
        _unitOfWorkMock = new Mock<IUnitOfWork>();
        _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<Guid>(),
            It.IsAny<CancellationToken>()))
            .ReturnsAsync(product);

        _unitOfWorkMock.Setup(x => x.Orders.AddAsync(
            It.IsAny<Order>(),
            It.IsAny<CancellationToken>()))
            .Returns(Task.CompletedTask);

        _unitOfWorkMock.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(1);

        var command = new CreateOrderCommand(
            CustomerEmail: "test@example.com",
            Items: new List<OrderItemDto>
            {
                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<Order>(o => o.CustomerEmail == "test@example.com"),
            It.IsAny<CancellationToken>()), Times.Once);
        _unitOfWorkMock.Verify(x => x.SaveChangesAsync(
            It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Handle_ProductNotFound_ThrowsNotFoundException()
    {
        // Arrange
        _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync(
            It.IsAny<Guid>(),
            It.IsAny<CancellationToken>()))
            .ReturnsAsync((Product?)null);

        var command = new CreateOrderCommand(
            CustomerEmail: "test@example.com",
            Items: new List<OrderItemDto>
            {
                new(ProductId: Guid.NewGuid(), Quantity: 1)
            });

        // Act & Assert
        await Assert.ThrowsAsync<NotFoundException>(
            () => _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

¡Empieza a practicar!

Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.

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.

Etiquetas

#dotnet
#clean architecture
#csharp
#aspnet core
#design patterns

Compartir

Artículos relacionados