Domain-Driven Design Aplicado a .NET 9
[ Domain-Driven Design Aplicado a .NET 9 ]

Configuración de un proyecto DDD en .NET 9

Este apéndice ofrece una guía práctica para configurar un proyecto basado en Domain-Driven Design (DDD) desde cero utilizando .NET 9. Como líder de KitsuneData Integral Solutions, he implementado múltiples proyectos DDD, y aquí te guiaré para estructurar un proyecto robusto y escalable. Cubriremos la creación del proyecto, la estructura de carpetas, la configuración de dependencias, la integración con Entity Framework Core 9 y Docker, y un ejemplo completo que extiende el sistema de e-commerce de los capítulos anteriores. Este capítulo es una referencia práctica para comenzar tu propio proyecto DDD.

9.1 Creación del proyecto y estructura inicial

Para iniciar un proyecto DDD en .NET 9, usaremos una estructura modular que separe el dominio, la aplicación, la infraestructura y la presentación. Esto alinea el código con los principios de DDD, como la separación de responsabilidades y el enfoque en el dominio.

Pasos iniciales
  1. Instala .NET 9 SDK: Asegúrate de tener el SDK de .NET 9 instalado (disponible en dotnet.microsoft.com).

  2. Crea una solución:

    dotnet new sln -n EcommerceDdd
    
  3. Crea proyectos para capas:

    • Dominio: Contiene entidades, objetos de valor, y lógica de negocio.
    • Aplicación: Define comandos, consultas, y servicios de aplicación.
    • Infraestructura: Implementa repositorios, persistencia, y comunicación.
    • API: Expone endpoints usando ASP.NET Core 9.
    dotnet new classlib -n EcommerceDdd.Domain -o src/EcommerceDdd.Domain
    dotnet new classlib -n EcommerceDdd.Application -o src/EcommerceDdd.Application
    dotnet new classlib -n EcommerceDdd.Infrastructure -o src/EcommerceDdd.Infrastructure
    dotnet new webapi -n EcommerceDdd.Api -o src/EcommerceDdd.Api
    dotnet sln add src/EcommerceDdd.Domain
    dotnet sln add src/EcommerceDdd.Application
    dotnet sln add src/EcommerceDdd.Infrastructure
    dotnet sln add src/EcommerceDdd.Api
    
Estructura de carpetas
EcommerceDdd/
├── src/
│   ├── EcommerceDdd.Domain/
│   │   ├── Entities/
│   │   ├── ValueObjects/
│   │   ├── Events/
│   ├── EcommerceDdd.Application/
│   │   ├── Commands/
│   │   ├── Queries/
│   │   ├── Services/
│   ├── EcommerceDdd.Infrastructure/
│   │   ├── Persistence/
│   │   ├── Messaging/
│   ├── EcommerceDdd.Api/
│   ├── tests/
│       ├── EcommerceDdd.Domain.Tests/
│       ├── EcommerceDdd.Application.Tests/

9.2 Configuración de dependencias

Configuraremos las dependencias necesarias, incluyendo Entity Framework Core 9 para persistencia, MediatR para CQRS, y RabbitMQ para eventos.

Dependencias del proyecto
  1. Dominio: Sin dependencias externas.
  2. Aplicación: Agrega MediatR.
    dotnet add src/EcommerceDdd.Application package MediatR
    
  3. Infraestructura: Agrega EF Core 9 y RabbitMQ.
    dotnet add src/EcommerceDdd.Infrastructure package Microsoft.EntityFrameworkCore.SqlServer
    dotnet add src/EcommerceDdd.Infrastructure package Microsoft.EntityFrameworkCore.Design
    dotnet add src/EcommerceDdd.Infrastructure package RabbitMQ.Client
    
  4. API: Agrega dependencias de las otras capas.
    dotnet add src/EcommerceDdd.Api reference src/EcommerceDdd.Application
    dotnet add src/EcommerceDdd.Api reference src/EcommerceDdd.Infrastructure
    
Configuración de la API

Actualiza EcommerceDdd.Api/Program.cs:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
builder.Services.AddScoped<IPedidoRepository, PedidoRepository>();
builder.Services.AddScoped<IDescuentoService, DescuentoService>();
builder.Services.AddSingleton<IEventoPublisher, RabbitMqEventoPublisher>();

var app = builder.Build();

app.MapControllers();

app.Run();

9.3 Configuración de Entity Framework Core 9

Configuraremos EF Core 9 para persistir el agregado Pedido del contexto de Gestión de Pedidos.

Modelo de dominio

En src/EcommerceDdd.Domain/Entities/Pedido.cs:

namespace EcommerceDdd.Domain.Entities;

public class Pedido
{
    public Guid Id { get; private set; }
    public Guid ClienteId { get; private set; }
    public List<LineaPedido> Lineas { get; private set; } = new();
    public decimal Total { get; private set; }
    public string Estado { get; private set; }
    public Direccion DireccionEnvio { get; private set; }
    public byte[] RowVersion { get; private set; }

    private Pedido() { } // Para EF Core

    public Pedido(Guid clienteId, Direccion direccionEnvio)
    {
        Id = Guid.NewGuid();
        ClienteId = clienteId;
        DireccionEnvio = direccionEnvio?.Validar() ?? throw new ArgumentNullException(nameof(direccionEnvio));
        Estado = "Creado";
    }

    public void AgregarLinea(Guid productoId, int cantidad, decimal precio)
    {
        var linea = new LineaPedido(productoId, cantidad, precio);
        Lineas.Add(linea);
        Total += linea.Subtotal;
    }

    public void Confirmar(IDescuentoService descuentoService)
    {
        if (Lineas.Count == 0)
            throw new InvalidOperationException("El pedido no tiene líneas.");
        Total = descuentoService.CalcularDescuento(ClienteId, Total);
        Estado = "Confirmado";
    }
}

En src/EcommerceDdd.Domain/ValueObjects/LineaPedido.cs:

namespace EcommerceDdd.Domain.ValueObjects;

public record LineaPedido(Guid ProductoId, int Cantidad, decimal Precio)
{
    public decimal Subtotal => Cantidad * Precio;
}

En src/EcommerceDdd.Domain/ValueObjects/Direccion.cs:

namespace EcommerceDdd.Domain.ValueObjects;

public record Direccion(string Calle, string Ciudad, string CodigoPostal)
{
    public Direccion Validar()
    {
        if (string.IsNullOrEmpty(Calle) || string.IsNullOrEmpty(Ciudad) || string.IsNullOrEmpty(CodigoPostal))
            throw new ArgumentException("Todos los campos de la dirección son requeridos.");
        return this;
    }
}
Configuración de EF Core

En src/EcommerceDdd.Infrastructure/Persistence/AppDbContext.cs:

namespace EcommerceDdd.Infrastructure.Persistence;

public class AppDbContext : DbContext
{
    public DbSet<Pedido> Pedidos { get; set; }

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Pedido>(entity =>
        {
            entity.HasKey(p => p.Id);
            entity.Property(p => p.ClienteId).IsRequired();
            entity.Property(p => p.Estado).IsRequired();
            entity.Property(p => p.Total).HasPrecision(18, 2);
            entity.Property(p => p.RowVersion).IsRowVersion();
            entity.OwnsMany(p => p.Lineas, linea =>
            {
                linea.WithOwner().HasForeignKey("PedidoId");
                linea.Property(l => l.ProductoId).IsRequired();
                linea.Property(l => l.Cantidad).IsRequired();
                linea.Property(l => l.Precio).HasPrecision(18, 2);
            });
            entity.OwnsOne(p => p.DireccionEnvio, dir =>
            {
                dir.Property(d => d.Calle).IsRequired();
                dir.Property(d => d.Ciudad).IsRequired();
                dir.Property(d => d.CodigoPostal).IsRequired();
            });
        });
    }
}
Repositorio

En src/EcommerceDdd.Infrastructure/Persistence/PedidoRepository.cs:

namespace EcommerceDdd.Infrastructure.Persistence;

public interface IPedidoRepository
{
    Task<Pedido> ObtenerPorIdAsync(Guid id);
    Task AgregarAsync(Pedido pedido);
    Task ActualizarAsync(Pedido pedido);
}

public class PedidoRepository : IPedidoRepository
{
    private readonly AppDbContext _context;

    public PedidoRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Pedido> ObtenerPorIdAsync(Guid id)
    {
        return await _context.Pedidos
            .Include(p => p.Lineas)
            .Include(p => p.DireccionEnvio)
            .FirstOrDefaultAsync(p => p.Id == id);
    }

    public async Task AgregarAsync(Pedido pedido)
    {
        await _context.Pedidos.AddAsync(pedido);
        await _context.SaveChangesAsync();
    }

    public async Task ActualizarAsync(Pedido pedido)
    {
        _context.Pedidos.Update(pedido);
        await _context.SaveChangesAsync();
    }
}

9.4 Configuración de CQRS con MediatR

Implementaremos CQRS para manejar comandos y consultas en el contexto de Gestión de Pedidos.

Comandos y consultas

En src/EcommerceDdd.Application/Commands/CrearPedidoCommand.cs:

namespace EcommerceDdd.Application.Commands;

public record CrearPedidoCommand(Guid ClienteId, string Calle, string Ciudad, string CodigoPostal) : IRequest;

public class CrearPedidoHandler : IRequestHandler<CrearPedidoCommand>
{
    private readonly IPedidoRepository _repository;

    public CrearPedidoHandler(IPedidoRepository repository)
    {
        _repository = repository;
    }

    public async Task Handle(CrearPedidoCommand request, CancellationToken cancellationToken)
    {
        var direccion = new Direccion(request.Calle, request.Ciudad, request.CodigoPostal).Validar();
        var pedido = new Pedido(request.ClienteId, direccion);
        await _repository.AgregarAsync(pedido);
    }
}

En src/EcommerceDdd.Application/Queries/ObtenerPedidoQuery.cs:

namespace EcommerceDdd.Application.Queries;

public record ObtenerPedidoQuery(Guid PedidoId) : IRequest<PedidoDto>;

public record PedidoDto(Guid Id, Guid ClienteId, decimal Total, string Estado);

public class ObtenerPedidoHandler : IRequestHandler<ObtenerPedidoQuery, PedidoDto>
{
    private readonly AppDbContext _context;

    public ObtenerPedidoHandler(AppDbContext context)
    {
        _context = context;
    }

    public async Task<PedidoDto> Handle(ObtenerPedidoQuery request, CancellationToken cancellationToken)
    {
        return await _context.Pedidos
            .Where(p => p.Id == request.PedidoId)
            .Select(p => new PedidoDto(p.Id, p.ClienteId, p.Total, p.Estado))
            .FirstOrDefaultAsync(cancellationToken);
    }
}

9.5 Configuración de eventos con RabbitMQ

Configuraremos la publicación de eventos para integrar con otros microservicios.

En src/EcommerceDdd.Infrastructure/Messaging/RabbitMqEventoPublisher.cs:

namespace EcommerceDdd.Infrastructure.Messaging;

public interface IEventoPublisher
{
    Task PublicarAsync<T>(T evento);
}

public class RabbitMqEventoPublisher : IEventoPublisher
{
    private readonly IConnection _connection;

    public RabbitMqEventoPublisher(IConnectionFactory connectionFactory)
    {
        _connection = connectionFactory.CreateConnection();
    }

    public async Task PublicarAsync<T>(T evento)
    {
        using var channel = _connection.CreateModel();
        var eventName = typeof(T).Name;
        channel.QueueDeclare(eventName, durable: true, exclusive: false, autoDelete: false);
        var body = JsonSerializer.SerializeToUtf8Bytes(evento);
        channel.BasicPublish("", eventName, null, body);
        await Task.CompletedTask;
    }
}
Evento de dominio

En src/EcommerceDdd.Domain/Events/PedidoConfirmadoEvent.cs:

namespace EcommerceDdd.Domain.Events;

public record PedidoConfirmadoEvent(Guid PedidoId, Guid ClienteId, decimal Total, DateTime FechaConfirmacion);

9.6 Configuración de Docker

Configuraremos Docker para desplegar el microservicio.

Dockerfile

En src/EcommerceDdd.Api/Dockerfile:

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 80

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["EcommerceDdd.Api/EcommerceDdd.Api.csproj", "EcommerceDdd.Api/"]
COPY ["EcommerceDdd.Application/EcommerceDdd.Application.csproj", "EcommerceDdd.Application/"]
COPY ["EcommerceDdd.Infrastructure/EcommerceDdd.Infrastructure.csproj", "EcommerceDdd.Infrastructure/"]
COPY ["EcommerceDdd.Domain/EcommerceDdd.Domain.csproj", "EcommerceDdd.Domain/"]
RUN dotnet restore "EcommerceDdd.Api/EcommerceDdd.Api.csproj"
COPY . .
WORKDIR "/src/EcommerceDdd.Api"
RUN dotnet build "EcommerceDdd.Api.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "EcommerceDdd.Api.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "EcommerceDdd.Api.dll"]
docker-compose.yml

En la raíz del proyecto:

version: '3.8'
services:
  pedidos-service:
    build:
      context: .
      dockerfile: src/EcommerceDdd.Api/Dockerfile
    ports:
      - "5000:80"
    depends_on:
      - sqlserver
      - rabbitmq
    environment:
      - ConnectionStrings__DefaultConnection=Server=sqlserver;Database=EcommerceDdd;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      - ACCEPT_EULA=Y
      - SA_PASSWORD=YourStrong@Passw0rd
    ports:
      - "1433:1433"
  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - "5672:5672"
      - "15672:15672"

9.7 Ejemplo completo

Uniremos todos los elementos en un microservicio de Gestión de Pedidos.

Servicio de aplicación

En src/EcommerceDdd.Application/Services/PedidoService.cs:

namespace EcommerceDdd.Application.Services;

public class PedidoService
{
    private readonly IPedidoRepository _repository;
    private readonly IDescuentoService _descuentoService;
    private readonly IEventoPublisher _publisher;

    public PedidoService(IPedidoRepository repository, IDescuentoService descuentoService, IEventoPublisher publisher)
    {
        _repository = repository;
        _descuentoService = descensoService;
        _publisher = publisher;
    }

    public async Task ConfirmarPedidoAsync(Guid pedidoId)
    {
        var pedido = await _repository.ObtenerPorIdAsync(pedidoId);
        if (pedido == null)
            throw new InvalidOperationException("Pedido no encontrado.");
        
        pedido.Confirmar(_descuentoService);
        await _repository.ActualizarAsync(pedido);
        await _publisher.PublicarAsync(new PedidoConfirmadoEvent(pedido.Id, pedido.ClienteId, pedido.Total, DateTime.UtcNow));
    }
}
Servicio de dominio

En src/EcommerceDdd.Domain/Services/IDescuentoService.cs:

namespace EcommerceDdd.Domain.Services;

public interface IDescuentoService
{
    decimal CalcularDescuento(Guid clienteId, decimal total);
}

public class DescuentoService : IDescuentoService
{
    public decimal CalcularDescuento(Guid clienteId, decimal total)
    {
        // Simulación: 10% de descuento para clientes VIP
        bool esClienteVip = false; // Consultar en implementación real
        return esClienteVip ? total * 0.9m : total;
    }
}
API

Actualiza src/EcommerceDdd.Api/Program.cs:

using EcommerceDdd.Application.Commands;
using EcommerceDdd.Application.Queries;
using EcommerceDdd.Application.Services;
using EcommerceDdd.Domain.Services;
using EcommerceDdd.Infrastructure.Messaging;
using EcommerceDdd.Infrastructure.Persistence;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
builder.Services.AddScoped<IPedidoRepository, PedidoRepository>();
builder.Services.AddScoped<IDescuentoService, DescuentoService>();
builder.Services.AddScoped<PedidoService>();
builder.Services.AddSingleton<IEventoPublisher, RabbitMqEventoPublisher>(sp =>
    new RabbitMqEventoPublisher(new ConnectionFactory { HostName = "rabbitmq" }));

var app = builder.Build();

app.MapPost("/pedidos", async (CrearPedidoCommand command, IMediator mediator) =>
{
    await mediator.Send(command);
    return Results.Created();
});

app.MapPost("/pedidos/confirmar/{id}", async (Guid id, PedidoService service) =>
{
    await service.ConfirmarPedidoAsync(id);
    return Results.Ok();
});

app.MapGet("/pedidos/{id}", async (Guid id, IMediator mediator) =>
{
    var pedido = await mediator.Send(new ObtenerPedidoQuery(id));
    return pedido != null ? Results.Ok(pedido) : Results.NotFound();
});

app.Run();

9.8 Ejecución del proyecto

  1. Configura la base de datos:
    dotnet ef migrations add InitialCreate -p src/EcommerceDdd.Infrastructure -s src/EcommerceDdd.Api
    dotnet ef database update -p src/EcommerceDdd.Infrastructure -s src/EcommerceDdd.Api
    
  2. Ejecuta con Docker:
    docker-compose up --build
    
  3. Prueba la API:
    • Crear un pedido: POST http://localhost:5000/pedidos con cuerpo { "ClienteId": "guid", "Calle": "Calle 123", "Ciudad": "Ciudad", "CodigoPostal": "12345" }.
    • Confirmar un pedido: POST http://localhost:5000/pedidos/confirmar/{id}.
    • Consultar un pedido: GET http://localhost:5000/pedidos/{id}.

9.9 Resumen

Este apéndice ha proporcionado una guía práctica para configurar un proyecto DDD en .NET 9, desde la estructura inicial hasta la implementación de un microservicio completo. En KitsuneData, usamos configuraciones similares para construir sistemas escalables que alinean el dominio con la tecnología. Esta configuración es un punto de partida que puedes adaptar según las necesidades de tu proyecto.