Domain-Driven-Design Konzepte mit EF Core

Domain-Driven-Design Konzepte mit EF Core

19. März 2024 | Gregor Koletzki | 3 Min. Lesezeit

Wie gut können die Domain-Driven-Design Konzepte an eine Datenbank mit EF Core gebunden werden? Diese Frage klären wir heute in diesem Artikel. EF Core bietet uns viele Möglichkeiten um Value Objekte oder Entitäten an eine Datenbank zu binden. In dem unteren Beispiel zeige ich euch wie z.B. eine Order Entität die Value Objekte beinhaltet per EF Core an die Datenbank gebunden werden kann. Also fangen wir direkt mit den Beispielen an.

Die Order Entität hält die identifiers als Value Objekte. Das ist ein Konzept in dem DDD was sich zu einem anderen Implementierung unterscheidet, wo die identifiers als Guid oder int abgelegt wird. Dieses Konzept gibt uns mehrere Vorteile. Wenn du mehr von dem Konzept erfahren willst, findest du hier den Artikel.

public class Order
{
    private List<LineItem>? _lineItems;
    private Order(OrderId id, CustomerId customerId)
    {
        Id = id;
        CustomerId = customerId;
    }

    public OrderId Id { get; }
    public CustomerId CustomerId { get; }
    [MemberNotNull(nameof(_lineItems))]
    public IReadOnlyList<LineItem> LineItems => _lineItems ?? throw new NavigationPropertyNotLoadedException();
    
    public static Order Create(CustomerId customerId)
    {
        return new Order(new OrderId(Guid.NewGuid()), customerId)
        {
            _lineItems = []
        };
    }

    public void AddLineItem(Money price, ProductId productId)
    {
        if (LineItems.Any(x => x.ProductId == productId))
        {
            return;
        }
        
        _lineItems.Add(LineItem.Create(Id, price, productId));
    }
}

Das LineItem hat eine weitere Ausprägung. Hier haben wir noch das Money Value Objekt welches die Währung (Currency) und den Wert (Amount) hält.

public class LineItem
{
    private LineItem(LineItemId id, OrderId orderId, ProductId productId)
    {
        Id = id;
        OrderId = orderId;
        ProductId = productId;
    }
    
    public LineItemId Id { get; }
    public OrderId OrderId { get; }
    public ProductId ProductId { get; }
    public Money Price { get; private init; }
    
    public static LineItem Create(OrderId orderId, Money price, ProductId productId)
    {
        return new LineItem(new LineItemId(Guid.NewGuid()), orderId, productId)
        {
            Price = price
        };
    }
}

Vollständigkeitshalber habe ich noch zu dem Beispiel die Entität Product hinzugefügt. Diese Beinhaltet ein Value Objekt VehicleIdentificationNumber welches wie man im unteren Beispiel sieht mit einer Factory Methode erzeugt wird.

public class Product
{
    public ProductId Id { get; }
    public VehicleIdentificationNumber VehicleIdentificationNumber { get; }

    private Product(ProductId id, VehicleIdentificationNumber vehicleIdentificationNumber)
    {
        Id = id;
        VehicleIdentificationNumber = vehicleIdentificationNumber;
    }

    public static Product Create(VehicleIdentificationNumber vehicleIdentificationNumber)
    {
        return new Product(new ProductId(Guid.NewGuid()), vehicleIdentificationNumber);
    }
}

Hier die Value Objelte die über einen Konstruktor erzeugt werden und keine Validierung auf Konsistenz des Wertes beinhalten.

public record LineItemId(Guid Value);
public record Money(string Currency, decimal Amount);
public record OrderId(Guid Value);
public record ProductId(Guid Value);
public record CustomerId(Guid Value);

Hier ein Value Objekt der durch die Factory Methode erzeugt wird und erstmal den Wert überprüft ob dieser überhaupt das Objekt sein darf.

public record VehicleIdentificationNumber
{
    public const int Length = 17;
    public string Value { get; }

    private VehicleIdentificationNumber(string value)
    {
        Value = value;
    }

    public static VehicleIdentificationNumber Create(string value)
    {
        if (value.Length != Length)
        {
            throw new ValidationException($"VIN does not correspond to the length of ${Length}");
        }

        // Weitere Checks
        
        return new VehicleIdentificationNumber(value);
    }
}

In dem Bereich der Implementierung von dem DbContext, ist erstmal nichts besonderes in der Methode OnModelCreating Registrieren wir alle Konfigurationen der Entitäten.

public class MyDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<LineItem> LineItems => Set<LineItem>();
    public DbSet<Product> Products => Set<Product>();
    
    public MyDbContext(DbContextOptions options)
        : base(options)
    {
    }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(MyDbContext).Assembly);
    }
}

Jetzt können wir in den Konfigurationen die Entitäten an die Datenbank binden. Um ein Value Objekt wie die Id zu binden haben wir die Möglichkeit uns der Methode HasConversion zu bedienen. Diese Methode hat mehrere Überladungen. Wir benutzen hier die Überladung mit den zwei Delegaten um EF zu sagen wie das Objekt auf der Datenbank gespeichert werden soll und wie das Objekt erzeugt werden soll wenn es von der Datenbank gelesen wird. Man kann auch einen eigenen Converter implementieren wenn man von der Klasse ValueConverter<> erbt.

public class OrderConfiguration : IEntityTypeConfiguration<Order.Order>
{
    public void Configure(EntityTypeBuilder<Order.Order> builder)
    {
        builder.HasKey(x => x.Id);
        builder.Property(x => x.Id)
            .HasConversion(
                key => key.Value, 
                value => new OrderId(value));

        builder.Property(x => x.CustomerId)
            .HasConversion(
                key => key.Value, 
                value => new CustomerId(value));

        builder.HasMany(x => x.LineItems)
            .WithOne()
            .HasForeignKey(x => x.OrderId);
    }
}

In den LineItems haben wir die Property Price die ein Value Objekt mit zwei Werten beinhaltet, der Currency und dem Amount. Hier können wir uns der Methode OwnsOne am EntityTyopeBuilder bedienen. Diese Methode ermöglicht uns ein Komplexes Objekt an die Tabelle zu binden. Die Methode hat zwei Parameter der erste ist die Peroperty die wir binden wollen, der zweite Parameter ist ein Delegat der uns einen OwnedNavigationBuilder reinreicht der für die Bindung von den Werten an der Property ist.

public class LineItemConfiguration : IEntityTypeConfiguration<LineItem>
{
    public void Configure(EntityTypeBuilder<LineItem> builder)
    {
        builder.HasKey(x => x.Id);
        builder.Property(x => x.Id)
            .HasConversion(
                x => x.Value, 
                value => new LineItemId(value));
        builder.Property(x => x.OrderId)
            .HasConversion(
                x => x.Value,
                value => new OrderId(value));
        builder.OwnsOne(x => x.Price, priceBuilder =>
        {
            priceBuilder.Property(x => x.Currency).HasMaxLength(3);
            priceBuilder.Property(x => x.Currency);
        });

        builder.HasOne<Product>()
            .WithMany()
            .HasForeignKey(x => x.ProductId)
            .IsRequired();
    }
}

In der ProductConfiguration zeige ich wie man ein Value Objekt binden kann, der über eine Factory Methode erstellt wird. Man verfährt genauso wie bei den anderen Value Objekt Beispielen, nur das man in HasConversion das Value Objekt über die Factory Methode erstellt.

public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.HasKey(x => x.Id);
        builder.Property(x => x.Id)
            .HasConversion(
                x => x.Value,
                x => new ProductId(x));

        builder.Property(x => x.VehicleIdentificationNumber)
            .HasConversion(
                x => x.Value,
                x => VehicleIdentificationNumber.Create(x))
            .HasMaxLength(VehicleIdentificationNumber.Length);
    }
}

Wenn wir die Migration über EF Core erstellen, sehen wir das die Value Objekt ohne Probleme gegen die Datenbank gebunden werden konnten. Bei dem Value Objekt Money sehen wir das die Spalte eine Namenkonvention bekommen hat die etwas so aussieht "PropertyName_ValueObjectPropertyName"

migrationBuilder.CreateTable(
    name: "Orders",
    columns: table => new
    {
        Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
        CustomerId = table.Column<Guid>(type: "uniqueidentifier", nullable: false)
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_Orders", x => x.Id);
    });

migrationBuilder.CreateTable(
    name: "Products",
    columns: table => new
    {
        Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
        VehicleIdentificationNumber = table.Column<string>(type: "nvarchar(17)", maxLength: 17, nullable: false)
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_Products", x => x.Id);
    });

migrationBuilder.CreateTable(
    name: "LineItems",
    columns: table => new
    {
        Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
        OrderId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
        ProductId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
        Price_Currency = table.Column<string>(type: "nvarchar(3)", maxLength: 3, nullable: false),
        Price_Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false)
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_LineItems", x => x.Id);
        table.ForeignKey(
            name: "FK_LineItems_Orders_OrderId",
            column: x => x.OrderId,
            principalTable: "Orders",
            principalColumn: "Id",
            onDelete: ReferentialAction.Cascade);
        table.ForeignKey(
            name: "FK_LineItems_Products_ProductId",
            column: x => x.ProductId,
            principalTable: "Products",
            principalColumn: "Id",
            onDelete: ReferentialAction.Cascade);
    });

migrationBuilder.CreateIndex(
    name: "IX_LineItems_OrderId",
    table: "LineItems",
    column: "OrderId");

migrationBuilder.CreateIndex(
    name: "IX_LineItems_ProductId",
    table: "LineItems",
    column: "ProductId");

Fazit

EF Core ermöglicht uns die Value Objekte und Entitäten aus dem DDD ohne Probleme zu mappen. Somit müssen wir keine Zwischenobjekte erstellen um die Daten auf der Datenbank zu speichern.

Weiterführende Artikel

Kategorien

Kontaktieren Sie uns:

Harksheider Weg 60H,
25451 Quickborn
+49 1520 40 73 253
info@gk-itsolutions.de

Schnellzugriff:

Folgt uns auf: