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.

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.
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.
# 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.InfrastructureEsta 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.
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.
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;
}
}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
}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.
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);
}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.
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
);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.
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
);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.
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.");
});
}
}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.
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);
}
}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);
}
}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);
}
}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
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.
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;
}
}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;
}
}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.
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.
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
Compartir
Artículos relacionados

Preguntas de Entrevista C# y .NET: Guía Completa 2026
Las 17 preguntas más frecuentes en entrevistas de C# y .NET. LINQ, async/await, inyección de dependencias, Entity Framework y buenas prácticas con respuestas detalladas.

.NET 8: Crear una API REST con ASP.NET Core
Guia completa para construir una API REST profesional con .NET 8 y ASP.NET Core. Controladores, Entity Framework Core, validacion y buenas practicas explicadas paso a paso.

Entity Framework Core: Optimización del Rendimiento y Buenas Prácticas en 2026
Guía completa de optimización de rendimiento con Entity Framework Core 10 en .NET 10. AsNoTracking, consultas compiladas, actualizaciones por lotes, split queries y LeftJoin.