Saltar al contenido principal

Clean Architecture 🧹

La arquitectura limpia de Robert C. Martin (Uncle Bob) enfatiza independencia de frameworks y facilidad de testing.


Conceptos Fundamentales

La Regla de Dependencia

Las dependencias SIEMPRE apuntan hacia adentro, nunca hacia afuera.

        OUTER LAYERS              INNER LAYERS
┌─────────────────┐ ┌──────────────┐
│ Web, DB, Mobile │──────→│ Entidades │
│ (External) │ │ (Core) │
└─────────────────┘ └──────────────┘

CORRECTO:
Presentation → Application → Domain
↓ ↓ ↑
No puede regresar así

INCORRECTO:
Domain ← Application (❌ Violaría Clean Arch)

Los 4 Anillos de Clean Architecture

                    ┌─────────────────────────────────────┐
│ FRAMEWORKS & DRIVERS │
│ (Web, DB, UI, Devices) │
│ ┌───────────────────────────────┐ │
│ │ INTERFACE ADAPTERS │ │
│ │ (Controllers, Presenters, │ │
│ │ Gateways, Repositories impl) │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ APPLICATION LOGIC │ │ │
│ │ │ (Use Cases, Commands, Queries)│ │ │
│ │ │ ┌───────────────────────────┐ │ │ │
│ │ │ │ ENTITIES │ │ │ │
│ │ │ │ (Objects, Rules, Events) │ │ │ │
│ │ │ └───────────────────────────┘ │ │ │
│ │ └─────────────────────────────┘ │ │ │
│ └───────────────────────────────┘ │ │
└─────────────────────────────────────┘ │

Capa 1: Entities (Dominio)

Las reglas de negocio más críticas, independientes del framework.

namespace MiApp.Domain.Entities
{
// ✅ PURO: Sin dependencias externas
public class Order
{
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public List<OrderLineItem> LineItems { get; private set; } = new();
public OrderStatus Status { get; private set; }
public decimal Total { get; private set; }

// REGLAS DE NEGOCIO encapsuladas
public void AddLineItem(OrderLineItem item)
{
if (item.Quantity <= 0)
throw new OrderException("Quantity must be positive");

if (Status == OrderStatus.Shipped)
throw new OrderException("Cannot add items to shipped order");

LineItems.Add(item);
RecalculateTotal();
}

public void ConfirmOrder()
{
if (Status != OrderStatus.Pending)
throw new OrderException("Only pending orders can be confirmed");

if (LineItems.Count == 0)
throw new OrderException("Order must have items");

Status = OrderStatus.Confirmed;
}

private void RecalculateTotal()
{
Total = LineItems.Sum(li => li.Total);
}
}

public record OrderLineItem
{
public int ProductId { get; set; }
public string ProductName { get; set; }
public decimal UnitPrice { get; set; }
public int Quantity { get; set; }
public decimal Total => UnitPrice * Quantity;
}

public enum OrderStatus { Pending, Confirmed, Shipped, Delivered }
}

// Prueba de la entidad (sin framework)
[TestClass]
public class OrderTests
{
[TestMethod]
public void AddLineItem_WithPositiveQuantity_ShouldSucceed()
{
var order = new Order { Id = Guid.NewGuid(), CustomerId = Guid.NewGuid() };
var item = new OrderLineItem { ProductId = 1, Quantity = 5, UnitPrice = 10 };

// ✅ Sin framework, solo C#
order.AddLineItem(item);

Assert.AreEqual(1, order.LineItems.Count);
Assert.AreEqual(50, order.Total);
}
}

Características:

  • ✅ Sin referencias a EF Core, DTOs, Controllers
  • ✅ Puro C#, fácil de testear
  • ✅ Encapsula toda la lógica de negocio
  • ✅ Es la capa más protegida

Capa 2: Application Logic (Casos de Uso)

Contiene los casos de uso específicos de la aplicación.

namespace MiApp.Application.UseCases
{
// Input (DTO)
public record CreateOrderRequest
{
public Guid CustomerId { get; set; }
public List<CreateLineItemRequest> Items { get; set; }
}

public record CreateLineItemRequest
{
public int ProductId { get; set; }
public int Quantity { get; set; }
}

// Output (DTO)
public record CreateOrderResponse
{
public Guid OrderId { get; set; }
public decimal Total { get; set; }
}

// Caso de uso
public class CreateOrderUseCase
{
private readonly IOrderRepository _orderRepository;
private readonly IProductService _productService;
private readonly IEmailService _emailService;

public CreateOrderUseCase(
IOrderRepository orderRepository,
IProductService productService,
IEmailService emailService)
{
_orderRepository = orderRepository;
_productService = productService;
_emailService = emailService;
}

public async Task<CreateOrderResponse> ExecuteAsync(CreateOrderRequest request)
{
// 1. Validar entrada
if (request.CustomerId == Guid.Empty)
throw new ArgumentException("Invalid customer");

// 2. Obtener datos (abstraído)
var productDetails = await _productService.GetProductsAsync(
request.Items.Select(i => i.ProductId).ToList()
);

// 3. Crear entidad (lógica de negocio)
var order = new Order { Id = Guid.NewGuid(), CustomerId = request.CustomerId };
foreach (var item in request.Items)
{
var product = productDetails.First(p => p.Id == item.ProductId);
order.AddLineItem(new OrderLineItem
{
ProductId = product.Id,
ProductName = product.Name,
UnitPrice = product.Price,
Quantity = item.Quantity
});
}

// 4. Persistencia (abstraída mediante interfaz)
await _orderRepository.SaveAsync(order);

// 5. Notificaciones (abstraídas)
await _emailService.SendConfirmationAsync(order.CustomerId, order);

return new CreateOrderResponse
{
OrderId = order.Id,
Total = order.Total
};
}
}
}

Características:

  • Depende de Domain e Interfaces
  • No conoce EF Core, Controllers, etc.
  • Usa Dependency Injection para abstraer detalles
  • Fácil de testear con mocks

Capa 3: Interface Adapters

Adapta datos entre casos de uso y el mundo exterior.

Repositories (Persistencia)

namespace MiApp.Domain.Interfaces
{
// ✅ INTERFAZ en Domain (la define el dominio)
public interface IOrderRepository
{
Task<Order> GetByIdAsync(Guid id);
Task SaveAsync(Order order);
}
}

namespace MiApp.Infrastructure.Persistence
{
// ✅ IMPLEMENTACIÓN en Infrastructure
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _dbContext;

public OrderRepository(AppDbContext dbContext)
{
_dbContext = dbContext;
}

public async Task<Order> GetByIdAsync(Guid id)
{
return await _dbContext.Orders
.Include(o => o.LineItems)
.FirstOrDefaultAsync(o => o.Id == id);
}

public async Task SaveAsync(Order order)
{
if (order.Id == Guid.Empty)
_dbContext.Orders.Add(order);
else
_dbContext.Orders.Update(order);

await _dbContext.SaveChangesAsync();
}
}
}

Controllers (Web)

namespace MiApp.API.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly CreateOrderUseCase _createOrderUseCase;

public OrdersController(CreateOrderUseCase createOrderUseCase)
{
_createOrderUseCase = createOrderUseCase;
}

[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOrderRequest request)
{
try
{
var response = await _createOrderUseCase.ExecuteAsync(request);
return Ok(response);
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
}
}

Responsabilidades:

  • Convertir HTTP → DTOs
  • Invocar Use Cases
  • Convertir respuestas → HTTP

Capa 4: Frameworks & Drivers

Web, Base de datos, librerías externas.

namespace MiApp.Infrastructure.Persistence
{
public class AppDbContext : DbContext
{
public DbSet<Order> Orders { get; set; }
public DbSet<Customer> Customers { get; set; }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Configuración de EF Core
modelBuilder.Entity<Order>()
.HasMany(o => o.LineItems)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}

Estructura de Carpetas Clean Architecture

YourApp/
├── YourApp.Domain/
│ ├── Entities/
│ │ ├── Order.cs
│ │ ├── Customer.cs
│ │ └── ValueObjects/
│ ├── Interfaces/ ← Abstracciones sin deps
│ │ ├── IRepository.cs
│ │ └── IEmailService.cs
│ ├── Events/
│ │ └── OrderCreatedEvent.cs
│ └── Exceptions/
│ └── OrderException.cs

├── YourApp.Application/
│ ├── UseCases/
│ │ ├── CreateOrder/
│ │ │ ├── CreateOrderUseCase.cs
│ │ │ ├── CreateOrderRequest.cs
│ │ │ └── CreateOrderResponse.cs
│ │ ├── GetOrder/
│ │ └── CancelOrder/
│ ├── Interfaces/ ← Aplicación las requiere
│ │ └── ICacheService.cs
│ └── Mappers/
│ └── OrderMappingProfile.cs

├── YourApp.Infrastructure/
│ ├── Persistence/
│ │ ├── AppDbContext.cs
│ │ ├── Configuration/
│ │ └── OrderRepository.cs ← Implementa IOrderRepository
│ ├── Services/
│ │ ├── EmailService.cs ← Implementa IEmailService
│ │ └── PdfGeneratorService.cs
│ ├── External/
│ │ └── StripePaymentClient.cs
│ └── Logging/

├── YourApp.API/
│ ├── Controllers/
│ │ └── OrdersController.cs
│ ├── Middleware/
│ ├── Filters/
│ └── Program.cs ← Inyección de dependencias

└── YourApp.Tests/
├── Unit/
│ ├── Domain/
│ │ └── OrderTests.cs
│ └── Application/
│ └── CreateOrderUseCaseTests.cs
└── Integration/
└── OrderControllerTests.cs

Inyección de Dependencias (Program.cs)

// Configurar todas las dependencias respetando Clean Architecture

var builder = WebApplication.CreateBuilder(args);

// Registrar Domain (no tiene dependencias)
// No necesita registro

// Registrar Application
builder.Services.AddScoped<CreateOrderUseCase>();
builder.Services.AddScoped<GetOrderUseCase>();

// Registrar Infrastructure (implementaciones)
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IProductService, ProductService>();

// Registrar DbContext (Framework)
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))
);

builder.Services.AddControllers();
var app = builder.Build();

app.MapControllers();
app.Run();

Testing en Clean Architecture

Unit Test (Entidad)

[TestClass]
public class OrderEntityTests
{
[TestMethod]
[ExpectedException(typeof(OrderException))]
public void ConfirmOrder_WithNoItems_ShouldThrow()
{
var order = new Order { Id = Guid.NewGuid() };
order.ConfirmOrder(); // ❌ Sin items
}

[TestMethod]
public void ConfirmOrder_WithItems_ShouldSucceed()
{
var order = new Order { Id = Guid.NewGuid() };
order.AddLineItem(new OrderLineItem { Quantity = 1, UnitPrice = 100 });

order.ConfirmOrder();

Assert.AreEqual(OrderStatus.Confirmed, order.Status);
}
}

Integration Test (Use Case)

[TestClass]
public class CreateOrderUseCaseTests
{
private Mock<IOrderRepository> _mockRepository;
private Mock<IEmailService> _mockEmailService;
private CreateOrderUseCase _useCase;

[TestInitialize]
public void Setup()
{
_mockRepository = new Mock<IOrderRepository>();
_mockEmailService = new Mock<IEmailService>();
_useCase = new CreateOrderUseCase(_mockRepository, _mockEmailService);
}

[TestMethod]
public async Task Execute_ShouldSaveOrderAndSendEmail()
{
var request = new CreateOrderRequest { ... };

var response = await _useCase.ExecuteAsync(request);

_mockRepository.Verify(r => r.SaveAsync(It.IsAny<Order>()), Times.Once);
_mockEmailService.Verify(e => e.SendConfirmationAsync(...), Times.Once);
Assert.IsNotNull(response.OrderId);
}
}

Beneficios de Clean Architecture

✅ Independencia de Frameworks
└─ Cambiar EF Core → Dapper sin afectar lógica

✅ Testeable
└─ 90%+ coverage, tests rápidos

✅ Mantenible
└─ Código organizado, responsabilidades claras

✅ Escalable
└─ Fácil agregar nuevas features

✅ Flexible
└─ Cambiar UI, DB, servicios externos sin afectar core

Comparativa: Layered vs Clean

AspectoLayeredClean
Capas3-4 (similar)4 (más explícito)
DependenciaHacia adentroHacia adentro (CRUCIAL)
TestingMedianoExcelente
FrameworksMás acopladosAislados en Infrastructure
Framework-agnosticParcialmente✅ Sí

Cuándo usar Clean Architecture

Usa Clean Architecture cuando:

  • Dominio es complejo y evolucionará
  • Requieres cambios frecuentes de frameworks
  • Necesitas testabilidad extrema
  • Es un proyecto long-term

NO es necesario para:

  • CRUD simples
  • Prototipos rápidos
  • MVPs descartables

Referencia

  • 📖 Clean Architecture — Robert C. Martin (Uncle Bob)
  • 🎥 Clean Architecture with ASP.NET Core — courses online

Última actualización: 2026-03-27