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
-
Instala .NET 9 SDK: Asegúrate de tener el SDK de .NET 9 instalado (disponible en
dotnet.microsoft.com
). -
Crea una solución:
dotnet new sln -n EcommerceDdd
-
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
- Dominio: Sin dependencias externas.
- Aplicación: Agrega MediatR.
dotnet add src/EcommerceDdd.Application package MediatR
- 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
- 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
- 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
- Ejecuta con Docker:
docker-compose up --build
- 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}
.
- Crear un pedido:
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.