Domain-Driven Design en .NET Core: Construyendo Software que Habla el Idioma del Negocio

Iván PeinadoIván Peinado
6 min read

El desarrollo de software empresarial puede convertirse rápidamente en un laberinto de código espagueti si no establecemos una arquitectura sólida desde el principio. Aquí es donde entra Domain-Driven Design (DDD), una filosofía de desarrollo que nos ayuda a crear aplicaciones que realmente reflejen la complejidad del mundo real.

Después de años trabajando con diferentes arquitecturas, he descubierto que DDD no es solo una metodología más, sino una forma completamente diferente de pensar sobre el código. En lugar de partir de la base de datos o la interfaz de usuario, comenzamos por entender profundamente el dominio del problema que estamos resolviendo.

🎯 ¿Por qué DDD en .NET Core?

.NET Core ofrece un ecosistema perfecto para implementar DDD gracias a su flexibilidad, inyección de dependencias nativa y su enfoque en la separación de responsabilidades. La combinación de ambos nos permite crear aplicaciones que no solo funcionan, sino que son mantenibles y escalables a largo plazo.

Imagina que estás desarrollando un sistema de comercio electrónico. Sin DDD, probablemente tendrías clases como ProductService, OrderService y CustomerService mezcladas con lógica de base de datos, validaciones y reglas de negocio dispersas por toda la aplicación. Con DDD, cada concepto tiene su lugar específico y habla el mismo idioma que los expertos del dominio.

📚 Los Pilares Fundamentales

🏛️ Entidades: La Identidad que Perdura

Las entidades son objetos que tienen identidad propia y persisten a lo largo del tiempo. No importa si cambias el nombre de un cliente o su dirección, sigue siendo el mismo cliente porque su identidad se mantiene.

public class Customer : Entity<CustomerId>
{
    public string FirstName { get; private set; }
    public string LastName { get; private set; }
    public Email Email { get; private set; }

    private readonly List<Order> _orders = new();
    public IReadOnlyCollection<Order> Orders => _orders.AsReadOnly();

    public Customer(CustomerId id, string firstName, string lastName, Email email)
        : base(id)
    {
        FirstName = Guard.Against.NullOrWhiteSpace(firstName);
        LastName = Guard.Against.NullOrWhiteSpace(lastName);
        Email = email;
    }

    public void UpdateContactInfo(string firstName, string lastName, Email email)
    {
        FirstName = Guard.Against.NullOrWhiteSpace(firstName);
        LastName = Guard.Against.NullOrWhiteSpace(lastName);
        Email = email;

        // Aquí podríamos disparar un evento del dominio
        AddDomainEvent(new CustomerContactUpdatedEvent(Id, email));
    }
}

La clave está en que las entidades encapsulan comportamiento, no solo datos. El método UpdateContactInfo no es simplemente un setter, sino que representa una acción del dominio con sus propias reglas y efectos secundarios.

🧩 Value Objects: Inmutabilidad y Expresividad

Los Value Objects representan conceptos que se definen por su valor, no por su identidad. Dos direcciones de email idénticas son intercambiables, sin importar dónde fueron creadas.

public class Email : ValueObject
{
    public string Value { get; }

    public Email(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            throw new DomainException("Email cannot be empty");

        if (!IsValidEmailFormat(value))
            throw new DomainException($"Invalid email format: {value}");

        Value = value.ToLowerInvariant();
    }

    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Value;
    }

    private static bool IsValidEmailFormat(string email) =>
        new EmailAddressAttribute().IsValid(email);
}

Los Value Objects nos permiten eliminar tipos primitivos obsesivos. En lugar de pasar string email por toda la aplicación, pasamos Email email, lo que hace el código más expresivo y elimina una categoría completa de bugs.

🏢 Agregados: Guardias de la Consistencia

Los agregados son grupos de entidades y value objects que se tratan como una unidad para propósitos de consistencia de datos. Cada agregado tiene una raíz que actúa como punto de entrada único.

public class Order : AggregateRoot<OrderId>
{
    public CustomerId CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public DateTime CreatedAt { get; private set; }

    private readonly List<OrderItem> _items = new();
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    public Money TotalAmount => _items
        .Aggregate(Money.Zero(), (acc, item) => acc + item.Total);

    public Order(OrderId id, CustomerId customerId) : base(id)
    {
        CustomerId = customerId;
        Status = OrderStatus.Draft;
        CreatedAt = DateTime.UtcNow;
    }

    public void AddItem(ProductId productId, int quantity, Money unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Cannot modify confirmed order");

        var existingItem = _items.FirstOrDefault(i => i.ProductId == productId);

        if (existingItem != null)
        {
            existingItem.UpdateQuantity(existingItem.Quantity + quantity);
        }
        else
        {
            _items.Add(new OrderItem(productId, quantity, unitPrice));
        }

        AddDomainEvent(new OrderItemAddedEvent(Id, productId, quantity));
    }

    public void Confirm()
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Order is already confirmed");

        if (!_items.Any())
            throw new DomainException("Cannot confirm empty order");

        Status = OrderStatus.Confirmed;
        AddDomainEvent(new OrderConfirmedEvent(Id, CustomerId, TotalAmount));
    }
}

El agregado Order mantiene la invariante de que no se pueden modificar pedidos confirmados y que no se pueden confirmar pedidos vacíos. Estas reglas están encapsuladas dentro del agregado, no dispersas por toda la aplicación.

📁 Repositorios: El Puente hacia la Persistencia

Los repositorios proporcionan una abstracción sobre el mecanismo de persistencia, permitiendo que el dominio se mantenga agnóstico a la tecnología de almacenamiento.

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(OrderId id, CancellationToken cancellationToken = default);
    Task<IEnumerable<Order>> GetByCustomerIdAsync(CustomerId customerId, CancellationToken cancellationToken = default);
    Task AddAsync(Order order, CancellationToken cancellationToken = default);
    Task UpdateAsync(Order order, CancellationToken cancellationToken = default);
    Task<bool> ExistsAsync(OrderId id, CancellationToken cancellationToken = default);
}

public class EfOrderRepository : IOrderRepository
{
    private readonly ApplicationDbContext _context;

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

    public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .Include(o => o.Items)
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task AddAsync(Order order, CancellationToken cancellationToken = default)
    {
        await _context.Orders.AddAsync(order, cancellationToken);
        await _context.SaveChangesAsync(cancellationToken);
    }
}

El repositorio habla en términos del dominio, no de SQL o Entity Framework. Esto nos permite cambiar la implementación sin afectar el código del dominio.

🎨 Organizando el Código: Arquitectura Limpia

La estructura de carpetas es crucial para mantener el código organizado:

src/
├── Domain/
│   ├── Entities/
│   ├── ValueObjects/
│   ├── Aggregates/
│   ├── Events/
│   ├── Exceptions/
│   └── Interfaces/
├── Application/
│   ├── Commands/
│   ├── Queries/
│   ├── Handlers/
│   └── Services/
├── Infrastructure/
│   ├── Persistence/
│   ├── Repositories/
│   └── Configuration/
└── Presentation/
    └── Controllers/

Esta estructura refleja la arquitectura hexagonal, donde el dominio está en el centro y las dependencias apuntan hacia adentro.

⚡ Manejando Eventos del Dominio

Los eventos del dominio nos permiten desacoplar efectos secundarios de las acciones principales:

public class OrderConfirmedEventHandler : INotificationHandler<OrderConfirmedEvent>
{
    private readonly IEmailService _emailService;
    private readonly IInventoryService _inventoryService;

    public OrderConfirmedEventHandler(IEmailService emailService, IInventoryService inventoryService)
    {
        _emailService = emailService;
        _inventoryService = inventoryService;
    }

    public async Task Handle(OrderConfirmedEvent notification, CancellationToken cancellationToken)
    {
        // Enviar email de confirmación
        await _emailService.SendOrderConfirmationAsync(notification.CustomerId, notification.OrderId);

        // Reservar inventario
        await _inventoryService.ReserveItemsAsync(notification.OrderId);
    }
}

🔧 Configuración en .NET Core

La inyección de dependencias nativa de .NET Core facilita mucho la configuración:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddDomainServices(this IServiceCollection services)
    {
        services.AddScoped<IOrderRepository, EfOrderRepository>();
        services.AddScoped<ICustomerRepository, EfCustomerRepository>();

        services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));

        return services;
    }
}

💡 Consejos Prácticos de la Experiencia Real

Empieza pequeño: No intentes modelar todo el dominio de una vez. Comienza con un bounded context bien definido y ve expandiendo gradualmente.

Colabora con expertos del dominio: Las mejores arquitecturas DDD nacen de conversaciones profundas con quienes realmente entienden el negocio.

No te obsesiones con la pureza: Está bien hacer compromisos pragmáticos. El objetivo es código mantenible, no académicamente perfecto.

Usa herramientas: Librerías como MediatR para CQRS, FluentValidation para validaciones, y Ardalis.GuardClauses para defensas pueden acelerar mucho el desarrollo.

🚀 El Resultado: Software que Evoluciona

Después de implementar DDD en varios proyectos, he notado que el código resultante no solo es más fácil de entender para desarrolladores nuevos en el equipo, sino que también es mucho más resistente a los cambios de requisitos.

Cuando llega una nueva funcionalidad, en lugar de preguntarnos "¿dónde diablos pongo este código?", tenemos un lugar natural donde cada pieza encaja. El dominio se convierte en una fuente de verdad que todos pueden consultar y entender.

DDD no es una bala de plata, pero cuando se aplica correctamente en .NET Core, transforma la experiencia de desarrollo de algo frustrante a algo genuinamente satisfactorio. El tiempo invertido en modelar correctamente el dominio se recupera con creces en facilidad de mantenimiento y velocidad de desarrollo a largo plazo.

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.