Clean Architecture en .NET Core: Organización real de un proyecto complejo

Iván PeinadoIván Peinado
6 min read

Cuando trabajamos en proyectos empresariales con .NET Core, uno de los mayores desafíos es mantener el código organizado, testeable y fácil de mantener a medida que crece la complejidad. La Clean Architecture se ha convertido en una solución probada para estos escenarios, pero implementarla correctamente requiere entender no solo los principios teóricos, sino también cómo aplicarlos en situaciones reales.

🎯 ¿Por qué Clean Architecture?

Después de años trabajando con diferentes arquitecturas, he observado que los proyectos tienden a volverse inmanejables cuando:

  • Las dependencias entre capas no están claras
  • La lógica de negocio se mezcla con detalles de infraestructura
  • Los tests se vuelven complejos de escribir
  • Los cambios pequeños requieren modificar múltiples archivos

La Clean Architecture aborda estos problemas estableciendo una separación clara de responsabilidades y una dirección específica de dependencias.

🔧 Estructura del proyecto: Más allá de la teoría

Organización de carpetas

src/
├── CompanyName.ProjectName.Domain/
├── CompanyName.ProjectName.Application/
├── CompanyName.ProjectName.Infrastructure/
└── CompanyName.ProjectName.WebApi/

tests/
├── CompanyName.ProjectName.UnitTests/
├── CompanyName.ProjectName.IntegrationTests/
└── CompanyName.ProjectName.ArchitectureTests/

Esta estructura no es arbitraria. Cada proyecto tiene un propósito específico y restricciones claras sobre qué puede referenciar.

📦 Domain Layer: El corazón del negocio

El dominio contiene las entidades, value objects, eventos de dominio y reglas de negocio. Es la capa más estable y no depende de nada externo.

// Entities/Customer.cs
public class Customer : BaseEntity
{
    public string Email { get; private set; }
    public string Name { get; private set; }
    public CustomerStatus Status { get; private set; }

    // Constructor privado para EF Core
    private Customer() { }

    public static Customer Create(string email, string name)
    {
        var customer = new Customer
        {
            Email = email,
            Name = name,
            Status = CustomerStatus.Active
        };

        customer.AddDomainEvent(new CustomerCreatedEvent(customer.Id));
        return customer;
    }

    public void Deactivate()
    {
        if (Status == CustomerStatus.Inactive)
            throw new DomainException("Customer is already inactive");

        Status = CustomerStatus.Inactive;
        AddDomainEvent(new CustomerDeactivatedEvent(Id));
    }
}

🚀 Application Layer: Orquestando casos de uso

La capa de aplicación define los casos de uso y orquesta las interacciones entre el dominio y la infraestructura.

// UseCases/CreateCustomer/CreateCustomerCommand.cs
public record CreateCustomerCommand(string Email, string Name) : IRequest<CustomerDto>;

public class CreateCustomerCommandHandler : IRequestHandler<CreateCustomerCommand, CustomerDto>
{
    private readonly ICustomerRepository _customerRepository;
    private readonly IEmailService _emailService;
    private readonly IUnitOfWork _unitOfWork;

    public CreateCustomerCommandHandler(
        ICustomerRepository customerRepository,
        IEmailService emailService,
        IUnitOfWork unitOfWork)
    {
        _customerRepository = customerRepository;
        _emailService = emailService;
        _unitOfWork = unitOfWork;
    }

    public async Task<CustomerDto> Handle(CreateCustomerCommand request, CancellationToken cancellationToken)
    {
        // Validación de negocio
        if (await _customerRepository.ExistsByEmailAsync(request.Email))
            throw new BusinessException("Customer with this email already exists");

        // Crear entidad de dominio
        var customer = Customer.Create(request.Email, request.Name);

        // Persistir
        await _customerRepository.AddAsync(customer);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        // Enviar email de bienvenida
        await _emailService.SendWelcomeEmailAsync(customer.Email, customer.Name);

        return CustomerDto.FromEntity(customer);
    }
}

🛠️ Implementación práctica: Lecciones aprendidas

🏗️ Infrastructure Layer: Conectando con el mundo exterior

La capa de infraestructura implementa las interfaces definidas en el dominio y la aplicación. Aquí es donde conectamos con bases de datos, servicios externos, sistemas de archivos y cualquier detalle técnico.

// Infrastructure/Persistence/CustomerRepository.cs
public class CustomerRepository : ICustomerRepository
{
    private readonly ApplicationDbContext _context;

    public CustomerRepository(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<Customer> GetByIdAsync(int id)
    {
        return await _context.Customers
            .Include(c => c.Orders)
            .FirstOrDefaultAsync(c => c.Id == id);
    }

    public async Task<bool> ExistsByEmailAsync(string email)
    {
        return await _context.Customers
            .AnyAsync(c => c.Email == email);
    }

    public async Task AddAsync(Customer customer)
    {
        await _context.Customers.AddAsync(customer);
    }

    public async Task<IEnumerable<Customer>> GetBySpecificationAsync(ISpecification<Customer> spec)
    {
        return await SpecificationEvaluator.GetQuery(_context.Customers, spec).ToListAsync();
    }
}

🔌 Configuración de Entity Framework

// Infrastructure/Persistence/ApplicationDbContext.cs
public class ApplicationDbContext : DbContext
{
    private readonly IDomainEventDispatcher _domainEventDispatcher;

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options, 
                              IDomainEventDispatcher domainEventDispatcher) 
        : base(options)
    {
        _domainEventDispatcher = domainEventDispatcher;
    }

    public DbSet<Customer> Customers { get; set; }
    public DbSet<Order> Orders { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
        base.OnModelCreating(modelBuilder);
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        var entities = ChangeTracker.Entries<BaseEntity>()
            .Where(e => e.Entity.DomainEvents.Any())
            .Select(e => e.Entity)
            .ToList();

        var result = await base.SaveChangesAsync(cancellationToken);

        // Dispatch domain events after successful save
        await _domainEventDispatcher.DispatchEventsAsync(entities);

        return result;
    }
}

📧 Implementación de servicios externos

// Infrastructure/Services/EmailService.cs
public class EmailService : IEmailService
{
    private readonly IConfiguration _configuration;
    private readonly ILogger<EmailService> _logger;

    public EmailService(IConfiguration configuration, ILogger<EmailService> logger)
    {
        _configuration = configuration;
        _logger = logger;
    }

    public async Task SendWelcomeEmailAsync(string email, string name)
    {
        try
        {
            var smtpClient = new SmtpClient(_configuration["Email:Host"])
            {
                Port = int.Parse(_configuration["Email:Port"]),
                Credentials = new NetworkCredential(
                    _configuration["Email:Username"],
                    _configuration["Email:Password"]),
                EnableSsl = true
            };

            var message = new MailMessage
            {
                From = new MailAddress(_configuration["Email:FromAddress"]),
                Subject = "Welcome to our platform!",
                Body = $"Welcome {name}! Thanks for joining us.",
                IsBodyHtml = true
            };

            message.To.Add(email);
            await smtpClient.SendMailAsync(message);

            _logger.LogInformation("Welcome email sent to {Email}", email);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to send welcome email to {Email}", email);
            throw;
        }
    }
}

Gestión de dependencias

Una de las partes más críticas es configurar correctamente la inyección de dependencias:

// Infrastructure/DependencyInjection.cs
public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Database
        services.AddDbContext<ApplicationDbContext>(options =>
            options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));

        // Repositories
        services.AddScoped<ICustomerRepository, CustomerRepository>();
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<IUnitOfWork, UnitOfWork>();

        // Services
        services.AddScoped<IEmailService, EmailService>();
        services.AddScoped<IDomainEventDispatcher, DomainEventDispatcher>();

        // External APIs
        services.AddHttpClient<IPaymentService, PaymentService>(client =>
        {
            client.BaseAddress = new Uri(configuration["PaymentApi:BaseUrl"]);
            client.DefaultRequestHeaders.Add("ApiKey", configuration["PaymentApi:ApiKey"]);
        });

        // Caching
        services.AddStackExchangeRedisCache(options =>
        {
            options.Configuration = configuration.GetConnectionString("Redis");
        });

        return services;
    }
}

🗄️ Configuración de entidades con Fluent API

// Infrastructure/Persistence/Configurations/CustomerConfiguration.cs
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.ToTable("Customers");

        builder.HasKey(c => c.Id);

        builder.Property(c => c.Email)
            .IsRequired()
            .HasMaxLength(255);

        builder.Property(c => c.Name)
            .IsRequired()
            .HasMaxLength(100);

        builder.Property(c => c.Status)
            .HasConversion<string>()
            .HasMaxLength(20);

        builder.HasIndex(c => c.Email)
            .IsUnique();

        // Ignore domain events for EF Core
        builder.Ignore(c => c.DomainEvents);

        // Configure relationships
        builder.HasMany(c => c.Orders)
            .WithOne(o => o.Customer)
            .HasForeignKey(o => o.CustomerId)
            .OnDelete(DeleteBehavior.Cascade);
    }
}

🔄 Manejo de eventos de dominio

Los eventos de dominio son fundamentales para mantener la consistencia y desacoplar las operaciones:

// Infrastructure/DomainEventDispatcher.cs
public class DomainEventDispatcher : IDomainEventDispatcher
{
    private readonly IMediator _mediator;

    public DomainEventDispatcher(IMediator mediator)
    {
        _mediator = mediator;
    }

    public async Task DispatchEventsAsync(IEnumerable<BaseEntity> entities)
    {
        var events = entities
            .SelectMany(e => e.DomainEvents)
            .ToList();

        entities.ToList().ForEach(e => e.ClearDomainEvents());

        foreach (var domainEvent in events)
        {
            await _mediator.Publish(domainEvent);
        }
    }
}

🧪 Testing en Clean Architecture

La arquitectura facilita enormemente las pruebas unitarias:

[Test]
public async Task CreateCustomer_WithValidData_ShouldCreateCustomer()
{
    // Arrange
    var command = new CreateCustomerCommand("test@test.com", "Test User");
    var mockRepository = new Mock<ICustomerRepository>();
    var mockEmailService = new Mock<IEmailService>();
    var mockUnitOfWork = new Mock<IUnitOfWork>();

    mockRepository.Setup(r => r.ExistsByEmailAsync(It.IsAny<string>()))
              .ReturnsAsync(false);

    var handler = new CreateCustomerCommandHandler(
        mockRepository.Object,
        mockEmailService.Object,
        mockUnitOfWork.Object);

    // Act
    var result = await handler.Handle(command, CancellationToken.None);

    // Assert
    Assert.That(result.Email, Is.EqualTo("test@test.com"));
    mockRepository.Verify(r => r.AddAsync(It.IsAny<Customer>()), Times.Once);
    mockEmailService.Verify(e => e.SendWelcomeEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Once);
}

⚡ Optimizaciones y patrones avanzados

CQRS Integration

Para proyectos complejos, la separación entre comandos y consultas aporta claridad:

// Queries/GetCustomerById/GetCustomerByIdQuery.cs
public record GetCustomerByIdQuery(int Id) : IRequest<CustomerDetailDto>;

public class GetCustomerByIdQueryHandler : IRequestHandler<GetCustomerByIdQuery, CustomerDetailDto>
{
    private readonly IReadOnlyRepository<Customer> _repository;

    public GetCustomerByIdQueryHandler(IReadOnlyRepository<Customer> repository)
    {
        _repository = repository;
    }

    public async Task<CustomerDetailDto> Handle(GetCustomerByIdQuery request, CancellationToken cancellationToken)
    {
        var customer = await _repository.GetByIdAsync(request.Id);
        return CustomerDetailDto.FromEntity(customer);
    }
}

Specification Pattern

Para consultas complejas, el patrón Specification mantiene el código limpio:

// Specifications/CustomerSpecifications.cs
public class ActiveCustomersSpecification : BaseSpecification<Customer>
{
    public ActiveCustomersSpecification() : base(c => c.Status == CustomerStatus.Active)
    {
        AddInclude(c => c.Orders);
        AddOrderBy(c => c.Name);
    }
}

🚨 Errores comunes y cómo evitarlos

1. Acoplar las capas incorrectamente

No permitas que el dominio dependa de la infraestructura. Usa interfaces y inversión de dependencias.

2. Entidades anémicas

Las entidades deben contener lógica de negocio, no ser simples contenedores de datos.

3. Servicios de dominio innecesarios

No todos los casos requieren servicios de dominio. Evalúa si la lógica pertenece a la entidad.

4. Mapeo excesivo

Aunque el mapeo es importante, no crees DTOs para cada operación si no aportan valor.

🎯 Conclusiones

La Clean Architecture en .NET Core no es solo una moda arquitectónica, es una herramienta poderosa para construir software mantenible y testeable. La inversión inicial en configurar correctamente la estructura se paga rápidamente en términos de productividad y calidad del código.

Los beneficios más tangibles que he observado incluyen:

  • Facilidad para escribir tests unitarios
  • Menor acoplamiento entre componentes
  • Claridad en la separación de responsabilidades
  • Flexibilidad para cambios en tecnologías de infraestructura
0
Subscribe to my newsletter

Read articles from Iván Peinado directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Iván Peinado
Iván Peinado

20 años de experiencia como desarrollador en tecnologías .NET. Desde que comencé mi aventura profesional no he dejado de interesarme por todo lo que rodea a esta tecnología. Me considero un apasionado de mi trabajo, intentando siempre aprender, evolucionar y conseguir unas metas y objetivos. La tecnología cambia constantemente y por ello es necesario tener una base consolidada y seguir adquiriendo nuevos y mayores conocimientos que hagan de nuestro trabajo más fácil. Intento siempre, aprender nuevas herramientas y funcionalidades relacionadas con la tecnología .NET que me ayude a seguir avanzando en mi carrera profesional y aportando nuevas ideas en los proyectos en los que participo.