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


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
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.