DEV Community

nikosst
nikosst

Posted on

Aggregates στο Domain-Driven Design: Πώς και γιατί τα χρησιμοποιούμε σε C# και .NET

"Πλήρης οδηγός για Aggregates και Aggregate Roots στο DDD, με πρακτικά παραδείγματα C#, EF Core, Clean Architecture και Business Rules."
tags: dotnet, csharp, architecture, ddd

Όταν ξεκινάμε την ανάπτυξη μιας εφαρμογής με C# και ASP.NET Core, συνήθως σκεφτόμαστε με όρους Controllers, Services, Repositories, Entities και Database Tables.

Αυτή η προσέγγιση λειτουργεί αρκετά καλά σε απλές CRUD εφαρμογές.

Τι συμβαίνει όμως όταν η επιχειρησιακή λογική γίνεται πιο σύνθετη;

Τι γίνεται όταν μια παραγγελία δεν πρέπει να αλλάζει μετά την ολοκλήρωσή της; Όταν δεν επιτρέπεται να ολοκληρωθεί χωρίς προϊόντα; Ή όταν πολλές διαφορετικές λειτουργίες πρέπει να διατηρούν τα δεδομένα σε έγκυρη κατάσταση;

Εδώ έρχεται μία από τις σημαντικότερες έννοιες του Domain-Driven Design (DDD):

Το Aggregate.

Σε αυτό το άρθρο θα δούμε:

  • Τι είναι το Aggregate και το Aggregate Root.
  • Ποια προβλήματα λύνουν στην αρχιτεκτονική μιας εφαρμογής.
  • Πώς σχετίζονται με Entities και Value Objects.
  • Πώς υλοποιούμε ένα Aggregate με C#.
  • Πώς το συνδέουμε με Entity Framework Core.
  • Πώς εφαρμόζουμε Repository Pattern και Clean Architecture.
  • Πώς επιλέγουμε σωστά τα Aggregate Boundaries.
  • Ποια είναι τα συνηθέστερα λάθη.

Ας ξεκινήσουμε.


1. Τι είναι ένα Aggregate;

Στο Domain-Driven Design, ένα Aggregate είναι μια ομάδα από Domain Objects που αντιμετωπίζεται ως μία ενιαία μονάδα επιχειρησιακής συνέπειας.

Μπορεί να περιλαμβάνει:

  • Ένα Aggregate Root.
  • Άλλα Entities.
  • Value Objects.

Το σημαντικότερο χαρακτηριστικό του είναι ότι οι αλλαγές στην εσωτερική του κατάσταση ελέγχονται μέσω του Aggregate Root.

Παράδειγμα

Ας υποθέσουμε ότι αναπτύσσουμε ένα e-commerce σύστημα.

Μια παραγγελία περιλαμβάνει:

Order (Aggregate Root)
│
├── OrderItem (Entity)
│   ├── ProductId
│   ├── Quantity
│   └── UnitPrice
│
├── OrderItem (Entity)
│   ├── ProductId
│   ├── Quantity
│   └── UnitPrice
│
└── OrderStatus
Enter fullscreen mode Exit fullscreen mode

Το Order είναι το Aggregate Root.

Αυτό σημαίνει ότι οι αλλαγές στα OrderItems πρέπει να πραγματοποιούνται μέσω του Order και όχι ανεξάρτητα.

Για παράδειγμα:

order.AddItem(productId, 2, 49.90m);
Enter fullscreen mode Exit fullscreen mode

Αντί για:

orderItem.Quantity = 2;
Enter fullscreen mode Exit fullscreen mode

Η διαφορά φαίνεται μικρή, αλλά έχει τεράστια αρχιτεκτονική σημασία.

Στην πρώτη περίπτωση, το Order ελέγχει αν επιτρέπεται η αλλαγή.

Στη δεύτερη, οποιοδήποτε σημείο της εφαρμογής μπορεί να τροποποιήσει το Entity χωρίς να εφαρμόσει τους επιχειρησιακούς κανόνες.


2. Γιατί χρειαζόμαστε Aggregates;

Ας δούμε ένα συνηθισμένο παράδειγμα από μια εφαρμογή που βασίζεται σε CRUD Services.

public async Task UpdateQuantity(
    Guid orderItemId,
    int quantity)
{
    var item = await dbContext.OrderItems
        .FindAsync(orderItemId);

    if (item is null)
        throw new Exception("Item not found");

    item.Quantity = quantity;

    await dbContext.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode

Αρχικά, ο κώδικας φαίνεται σωστός.

Αλλά υπάρχουν αρκετά προβλήματα.

Πρόβλημα 1: Δεν προστατεύονται οι Business Rules.

Τι γίνεται αν η παραγγελία έχει ήδη ολοκληρωθεί;

Πρόβλημα 2: Η επιχειρησιακή λογική βρίσκεται στο Application Service.

Αν χρειαστεί η ίδια λειτουργία από διαφορετικό σημείο της εφαρμογής, υπάρχει κίνδυνος να αντιγραφούν ή να παρακαμφθούν οι έλεγχοι.

Πρόβλημα 3: Το Domain Model είναι Anemic.

Τα Entities περιέχουν δεδομένα, αλλά όχι την επιχειρησιακή συμπεριφορά που τα αφορά.

Με το Aggregate, η ενέργεια μεταφέρεται στο Domain:

order.ChangeItemQuantity(productId, 5);
Enter fullscreen mode Exit fullscreen mode

Το ίδιο το Order αναλαμβάνει να αποφασίσει αν επιτρέπεται η αλλαγή.

Αυτός είναι ο βασικός στόχος: να προστατεύουμε τους επιχειρησιακούς κανόνες μέσα στο Domain Model.


3. Aggregate Root vs Entity vs Value Object

Πριν προχωρήσουμε στην υλοποίηση, πρέπει να κατανοήσουμε τρεις βασικές έννοιες.

Entity

Ένα Entity είναι ένα αντικείμενο με μοναδική ταυτότητα.

Για παράδειγμα:

public class Customer
{
    public Guid Id { get; private set; }

    public string Name { get; private set; }
}
Enter fullscreen mode Exit fullscreen mode

Δύο Customers με το ίδιο όνομα δεν είναι απαραίτητα ο ίδιος Customer.

Η ταυτότητά τους καθορίζεται από το Id.

Value Object

Ένα Value Object δεν έχει δική του επιχειρησιακή ταυτότητα.

Η ισότητά του καθορίζεται από τις τιμές του.

public sealed record Money(
    decimal Amount,
    string Currency);
Enter fullscreen mode Exit fullscreen mode

Για παράδειγμα:

var first = new Money(100, "EUR");
var second = new Money(100, "EUR");

Console.WriteLine(first == second); // True
Enter fullscreen mode Exit fullscreen mode

Σε production υλοποίηση θα προσθέταμε validation για το νόμισμα, το ποσό και τις επιτρεπόμενες πράξεις.

Aggregate Root

Το Aggregate Root είναι ένα Entity που αποτελεί το κεντρικό σημείο πρόσβασης σε ένα Aggregate.

Έχει την ευθύνη να προστατεύει τους κανόνες που πρέπει να ισχύουν σε όλη τη μονάδα συνέπειας.

Για παράδειγμα, ένα Order Aggregate μπορεί να επιβάλλει τους κανόνες:

  1. Δεν επιτρέπονται προϊόντα με αρνητική ποσότητα.
  2. Δεν επιτρέπονται αλλαγές σε ολοκληρωμένη παραγγελία.
  3. Μια παραγγελία πρέπει να περιέχει τουλάχιστον ένα προϊόν για να ολοκληρωθεί.
  4. Το συνολικό ποσό υπολογίζεται από τα OrderItems.

4. Υλοποιώντας το OrderItem Entity

Αρχικά, δημιουργούμε το OrderItem.

public sealed class OrderItem
{
    public Guid Id { get; private set; }

    public Guid ProductId { get; private set; }

    public int Quantity { get; private set; }

    public decimal UnitPrice { get; private set; }

    private OrderItem() { } // EF Core

    internal OrderItem(
        Guid productId,
        int quantity,
        decimal unitPrice)
    {
        if (productId == Guid.Empty)
            throw new ArgumentException(
                "Invalid product.");

        if (quantity <= 0)
            throw new ArgumentException(
                "Quantity must be positive.");

        if (unitPrice < 0)
            throw new ArgumentException(
                "Unit price cannot be negative.");

        Id = Guid.NewGuid();
        ProductId = productId;
        Quantity = quantity;
        UnitPrice = unitPrice;
    }

    internal void ChangeQuantity(int quantity)
    {
        if (quantity <= 0)
            throw new ArgumentException(
                "Quantity must be positive.");

        Quantity = quantity;
    }

    public decimal TotalPrice =>
        Quantity * UnitPrice;
}
Enter fullscreen mode Exit fullscreen mode

Παρατηρούμε κάτι σημαντικό.

Το Quantity έχει private setter:

public int Quantity { get; private set; }
Enter fullscreen mode Exit fullscreen mode

Επίσης, η μέθοδος αλλαγής ποσότητας δεν είναι public:

internal void ChangeQuantity(int quantity)
Enter fullscreen mode Exit fullscreen mode

Αυτό περιορίζει τη δυνατότητα εξωτερικής τροποποίησης.

Η χρήση του internal αποτελεί έναν πρώτο περιορισμό πρόσβασης σε επίπεδο assembly. Δεν εξασφαλίζει από μόνη της ότι μόνο το Aggregate Root μπορεί να καλέσει τη μέθοδο.

Για αυστηρότερη προστασία μπορούμε να χρησιμοποιήσουμε επιπλέον encapsulation ή διαφορετική οργάνωση των Domain Types.


5. Υλοποιώντας το Order Aggregate Root

Ας δημιουργήσουμε πρώτα το OrderStatus.

public enum OrderStatus
{
    Draft = 0,
    Completed = 1,
    Cancelled = 2
}
Enter fullscreen mode Exit fullscreen mode

Στη συνέχεια δημιουργούμε το Aggregate Root.

public sealed class Order
{
    private readonly List<OrderItem> _items = new();

    public Guid Id { get; private set; }

    public Guid CustomerId { get; private set; }

    public OrderStatus Status { get; private set; }

    public IReadOnlyCollection<OrderItem> Items =>
        _items.AsReadOnly();

    public decimal TotalAmount =>
        _items.Sum(x => x.TotalPrice);

    private Order() { } // EF Core

    public Order(Guid customerId)
    {
        if (customerId == Guid.Empty)
            throw new ArgumentException(
                "Invalid customer.");

        Id = Guid.NewGuid();
        CustomerId = customerId;
        Status = OrderStatus.Draft;
    }

    public void AddItem(
        Guid productId,
        int quantity,
        decimal unitPrice)
    {
        EnsureDraft();

        if (_items.Any(x => x.ProductId == productId))
            throw new InvalidOperationException(
                "Product already exists.");

        var item = new OrderItem(
            productId,
            quantity,
            unitPrice);

        _items.Add(item);
    }

    public void ChangeItemQuantity(
        Guid productId,
        int quantity)
    {
        EnsureDraft();

        var item = _items.SingleOrDefault(
            x => x.ProductId == productId);

        if (item is null)
            throw new InvalidOperationException(
                "Product not found.");

        item.ChangeQuantity(quantity);
    }

    public void RemoveItem(Guid productId)
    {
        EnsureDraft();

        var item = _items.SingleOrDefault(
            x => x.ProductId == productId);

        if (item is null)
            throw new InvalidOperationException(
                "Product not found.");

        _items.Remove(item);
    }

    public void Complete()
    {
        EnsureDraft();

        if (_items.Count == 0)
            throw new InvalidOperationException(
                "Cannot complete an empty order.");

        Status = OrderStatus.Completed;
    }

    public void Cancel()
    {
        EnsureDraft();

        Status = OrderStatus.Cancelled;
    }

    private void EnsureDraft()
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException(
                "Order cannot be modified.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Τι κερδίσαμε;

Πλέον δεν μπορούμε να αλλάξουμε ανεξέλεγκτα την κατάσταση της παραγγελίας.

Όλες οι αλλαγές περνούν από συγκεκριμένες Domain Methods.

order.AddItem(productId, 2, 50m);

order.ChangeItemQuantity(productId, 3);

order.Complete();
Enter fullscreen mode Exit fullscreen mode

Αν προσπαθήσουμε να αλλάξουμε την παραγγελία μετά την ολοκλήρωσή της:

order.AddItem(
    anotherProductId,
    1,
    20m);
Enter fullscreen mode Exit fullscreen mode

Θα προκύψει exception.

Ο επιχειρησιακός κανόνας προστατεύεται ανεξάρτητα από το αν η ενέργεια ξεκίνησε από REST API, background job ή message consumer.

Αυτό είναι το πραγματικό πλεονέκτημα του Aggregate.


6. Aggregates και Entity Framework Core

Ένα Aggregate δεν είναι το ίδιο πράγμα με ένα Database Table.

Το Aggregate αποτελεί έννοια του Domain Model, ενώ το Entity Framework Core αναλαμβάνει το persistence.

Μπορούμε να χαρτογραφήσουμε το Order και τα OrderItems χρησιμοποιώντας Fluent API.

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

    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(
        ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>(order =>
        {
            order.HasKey(x => x.Id);

            order.Property(x => x.Status)
                .HasConversion<int>();

            order.Ignore(x => x.TotalAmount);

            order.HasMany(x => x.Items)
                .WithOne()
                .HasForeignKey("OrderId")
                .OnDelete(DeleteBehavior.Cascade);

            order.Navigation(x => x.Items)
                .HasField("_items");

            order.Navigation(x => x.Items)
                .UsePropertyAccessMode(
                    PropertyAccessMode.Field);
        });

        modelBuilder.Entity<OrderItem>(item =>
        {
            item.HasKey(x => x.Id);

            item.Property(x => x.UnitPrice)
                .HasPrecision(18, 2);

            item.Ignore(x => x.TotalPrice);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Το OrderItem δεν χρειάζεται να εκτίθεται ως δημόσιο DbSet.

Έτσι εκφράζουμε την πρόθεσή μας να χειριζόμαστε το Order ως μία ενιαία επιχειρησιακή μονάδα.

Η φόρτωση του Aggregate μπορεί να γίνει ως εξής:

var order = await dbContext.Orders
    .Include(x => x.Items)
    .SingleAsync(x => x.Id == orderId);
Enter fullscreen mode Exit fullscreen mode

Και στη συνέχεια:

order.AddItem(productId, 2, 49.90m);

await dbContext.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

Το EF Core αναλαμβάνει το change tracking και το persistence.

Σημαντικό: Το mapping δεν επιβάλλει από μόνο του τους κανόνες πρόσβασης του DDD. Αυτοί πρέπει να προστατεύονται από το Domain API και την αρχιτεκτονική της εφαρμογής.


7. Repository Pattern και Aggregates

Στο Domain-Driven Design, τα Repositories συνήθως σχεδιάζονται γύρω από Aggregate Roots.

Για παράδειγμα:

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken);

    Task AddAsync(
        Order order,
        CancellationToken cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

Η υλοποίηση με EF Core:

public sealed class OrderRepository
    : IOrderRepository
{
    private readonly AppDbContext _context;

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

    public Task<Order?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken)
    {
        return _context.Orders
            .Include(x => x.Items)
            .SingleOrDefaultAsync(
                x => x.Id == id,
                cancellationToken);
    }

    public async Task AddAsync(
        Order order,
        CancellationToken cancellationToken)
    {
        await _context.Orders.AddAsync(
            order,
            cancellationToken);
    }
}
Enter fullscreen mode Exit fullscreen mode

Δεν χρειάζεται να δημιουργήσουμε ξεχωριστό Repository για κάθε OrderItem.

Το OrderItem ανήκει στο Order Aggregate.

Επομένως, οι αλλαγές του γίνονται μέσω του Order.

Χρειαζόμαστε πάντα Repository Pattern;

Όχι απαραίτητα.

Το EF Core DbContext ήδη προσφέρει χαρακτηριστικά Repository και Unit of Work.

Ένα custom Repository είναι χρήσιμο όταν θέλουμε να απομονώσουμε το Domain/Application Layer από τις λεπτομέρειες του persistence ή να ορίσουμε συγκεκριμένες λειτουργίες ανάκτησης Aggregates.

Δεν πρέπει να προσθέτουμε abstractions χωρίς ουσιαστικό όφελος.


8. Aggregates μέσα στην Clean Architecture

Μια τυπική δομή έργου μπορεί να είναι:

src/
│
├── MyApp.Domain/
│   ├── Orders/
│   │   ├── Order.cs
│   │   ├── OrderItem.cs
│   │   └── OrderStatus.cs
│   │
│   └── Abstractions/
│       └── IOrderRepository.cs
│
├── MyApp.Application/
│   └── Orders/
│       └── CompleteOrder/
│           └── CompleteOrderHandler.cs
│
├── MyApp.Infrastructure/
│   ├── Persistence/
│   │   └── AppDbContext.cs
│   │
│   └── Repositories/
│       └── OrderRepository.cs
│
└── MyApp.Api/
    └── Controllers/
        └── OrdersController.cs
Enter fullscreen mode Exit fullscreen mode

Το Domain δεν πρέπει να εξαρτάται από το Entity Framework Core ή από το ASP.NET Core.

Οι επιχειρησιακοί κανόνες πρέπει να μπορούν να εκτελεστούν και να ελεγχθούν ανεξάρτητα από τις τεχνολογίες υποδομής.

Application Handler

public sealed class CompleteOrderHandler
{
    private readonly IOrderRepository _repository;
    private readonly IUnitOfWork _unitOfWork;

    public CompleteOrderHandler(
        IOrderRepository repository,
        IUnitOfWork unitOfWork)
    {
        _repository = repository;
        _unitOfWork = unitOfWork;
    }

    public async Task Handle(
        Guid orderId,
        CancellationToken cancellationToken)
    {
        var order = await _repository.GetByIdAsync(
            orderId,
            cancellationToken);

        if (order is null)
            throw new KeyNotFoundException(
                "Order not found.");

        order.Complete();

        await _unitOfWork.SaveChangesAsync(
            cancellationToken);
    }
}
Enter fullscreen mode Exit fullscreen mode

Εδώ το IUnitOfWork είναι ένα interface του Application ή Domain abstraction layer, με υλοποίηση στο Infrastructure.

Για παράδειγμα:

public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(
        CancellationToken cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

Ο Handler δεν περιέχει τον κανόνα που καθορίζει αν μια παραγγελία μπορεί να ολοκληρωθεί.

Απλώς καλεί:

order.Complete();
Enter fullscreen mode Exit fullscreen mode

Η επιχειρησιακή απόφαση παραμένει στο Domain.


9. Aggregate = Consistency Boundary

Ένα από τα σημαντικότερα χαρακτηριστικά των Aggregates είναι ότι ορίζουν τα όρια της επιχειρησιακής συνέπειας.

Ας υποθέσουμε ότι έχουμε δύο Aggregates:

Order Aggregate
├── Order
└── OrderItems

Inventory Aggregate
├── InventoryItem
└── StockReservations
Enter fullscreen mode Exit fullscreen mode

Το Order είναι υπεύθυνο για τους κανόνες της παραγγελίας.

Το Inventory είναι υπεύθυνο για τους κανόνες διαθεσιμότητας και δέσμευσης αποθέματος.

Δεν πρέπει να δημιουργήσουμε ένα τεράστιο Aggregate που περιλαμβάνει όλα τα παραπάνω μόνο και μόνο επειδή σχετίζονται μεταξύ τους.

Πώς επικοινωνούν διαφορετικά Aggregates;

Μέσω Application Services, Domain Events ή Integration Events, ανάλογα με τις απαιτήσεις.

Για παράδειγμα:

Order Completed
       |
       v
OrderCompleted Event
       |
       v
Inventory Reservation Workflow
       |
       v
Inventory Aggregate
Enter fullscreen mode Exit fullscreen mode

Μια πιθανή υλοποίηση Domain Event:

public sealed record OrderCompletedDomainEvent(
    Guid OrderId,
    Guid CustomerId);
Enter fullscreen mode Exit fullscreen mode

Το Aggregate μπορεί να καταγράφει το event κατά την ολοκλήρωση.

Ένας Application Handler μπορεί στη συνέχεια να συντονίζει την ενημέρωση του Inventory.

Σε distributed architectures, μπορούμε να χρησιμοποιήσουμε:

  • Transactional Outbox Pattern.
  • Idempotent Message Consumers.
  • Eventual Consistency.
  • Saga / Process Manager.

Δεν είναι όμως υποχρεωτικό κάθε επικοινωνία μεταξύ Aggregates να γίνεται ασύγχρονα.

Η επιλογή εξαρτάται από τις απαιτήσεις ατομικότητας, συνέπειας και διαθεσιμότητας.


10. Πώς αποφασίζουμε τα Aggregate Boundaries;

Αυτό είναι ίσως το σημαντικότερο και δυσκολότερο μέρος του σχεδιασμού.

Ένα συνηθισμένο λάθος είναι να θεωρούμε ότι κάθε σχέση μεταξύ Database Tables πρέπει να αντιστοιχεί σε ένα Aggregate.

Αυτό δεν ισχύει.

Τα Aggregate Boundaries καθορίζονται κυρίως από τους επιχειρησιακούς κανόνες και όχι από τα Foreign Keys.

Ερωτήσεις που πρέπει να κάνουμε

1. Ποιοι κανόνες πρέπει να ισχύουν άμεσα και ατομικά;

Αν μια αλλαγή πρέπει να διατηρεί συγκεκριμένα invariants σε πολλά Entities, αυτά ενδέχεται να ανήκουν στο ίδιο Aggregate.

2. Ποια αντικείμενα έχουν ανεξάρτητο lifecycle;

Αν ένα αντικείμενο μπορεί να δημιουργείται, να τροποποιείται και να διαγράφεται ανεξάρτητα, ίσως χρειάζεται δικό του Aggregate.

3. Πόσο μεγάλο γίνεται το Aggregate;

Ένα υπερβολικά μεγάλο Aggregate μπορεί να δημιουργήσει προβλήματα:

  • Concurrency conflicts.
  • Performance overhead.
  • Μεγάλα object graphs.
  • Δυσκολότερη συντήρηση.

4. Μπορούμε να χρησιμοποιήσουμε eventual consistency;

Αν δεν χρειάζεται άμεση συνέπεια μεταξύ δύο επιχειρησιακών λειτουργιών, ίσως είναι προτιμότερο να βρίσκονται σε διαφορετικά Aggregates.

Ένας πρακτικός κανόνας

Σχεδίασε το μικρότερο δυνατό Aggregate που μπορεί να προστατεύσει σωστά τα επιχειρησιακά invariants του.


11. Aggregates και Concurrency

Ακόμη και ένα σωστά σχεδιασμένο Aggregate μπορεί να αντιμετωπίσει προβλήματα όταν πολλοί χρήστες το τροποποιούν ταυτόχρονα.

Ας υποθέσουμε ότι δύο requests φορτώνουν την ίδια παραγγελία.

Request A                 Request B
    |                         |
Load Order               Load Order
    |                         |
Change Quantity          Complete Order
    |                         |
Save Changes             Save Changes
Enter fullscreen mode Exit fullscreen mode

Χωρίς concurrency protection, μπορεί να προκύψουν χαμένες ενημερώσεις ή μη έγκυρη τελική κατάσταση.

Το Aggregate προστατεύει τους κανόνες στο επίπεδο του Domain Model, αλλά δεν αντικαθιστά τους μηχανισμούς concurrency του persistence layer.

Optimistic Concurrency με EF Core

Μπορούμε να χρησιμοποιήσουμε ένα concurrency token.

Για παράδειγμα, σε SQL Server:

public byte[] RowVersion { get; private set; }
    = Array.Empty<byte>();
Enter fullscreen mode Exit fullscreen mode

Και στο mapping:

order.Property(x => x.RowVersion)
    .IsRowVersion();
Enter fullscreen mode Exit fullscreen mode

Το EF Core μπορεί να εντοπίσει συγκρούσεις ενημέρωσης και να προκαλέσει DbUpdateConcurrencyException.

Για Aggregates με child entities χρειάζεται προσεκτικός σχεδιασμός ώστε οι αλλαγές των παιδιών να συμμετέχουν στην επιθυμητή στρατηγική concurrency. Ένα row version μόνο στον πίνακα του Root δεν προστατεύει αυτόματα όλες τις ενημερώσεις των child rows.


12. Unit Testing των Aggregates

Ένα από τα μεγαλύτερα πλεονεκτήματα του συγκεκριμένου σχεδιασμού είναι ότι μπορούμε να κάνουμε unit testing χωρίς database.

Με xUnit:

public sealed class OrderTests
{
    [Fact]
    public void Cannot_complete_empty_order()
    {
        var order = new Order(Guid.NewGuid());

        Assert.Throws<InvalidOperationException>(
            () => order.Complete());
    }

    [Fact]
    public void Cannot_modify_completed_order()
    {
        var order = new Order(Guid.NewGuid());

        order.AddItem(
            Guid.NewGuid(),
            2,
            50m);

        order.Complete();

        Assert.Throws<InvalidOperationException>(
            () => order.AddItem(
                Guid.NewGuid(),
                1,
                20m));
    }

    [Fact]
    public void Total_amount_is_calculated_correctly()
    {
        var order = new Order(Guid.NewGuid());

        order.AddItem(Guid.NewGuid(), 2, 50m);
        order.AddItem(Guid.NewGuid(), 1, 30m);

        Assert.Equal(130m, order.TotalAmount);
    }

    [Fact]
    public void Cannot_add_duplicate_product()
    {
        var order = new Order(Guid.NewGuid());
        var productId = Guid.NewGuid();

        order.AddItem(productId, 1, 20m);

        Assert.Throws<InvalidOperationException>(
            () => order.AddItem(productId, 2, 20m));
    }
}
Enter fullscreen mode Exit fullscreen mode

Τα tests αυτά εκτελούνται χωρίς:

  • SQL Server.
  • Entity Framework Core.
  • HTTP Requests.
  • Dependency Injection.
  • Mock Repositories.

Ελέγχουμε απευθείας την επιχειρησιακή συμπεριφορά.

Αυτό κάνει το Domain Model πιο εύκολο να κατανοηθεί και να εξελιχθεί.


13. Συνηθισμένα λάθη στην εφαρμογή Aggregates

Λάθος 1: Public Setters παντού

public OrderStatus Status { get; set; }
Enter fullscreen mode Exit fullscreen mode

Έτσι επιτρέπουμε σε οποιοδήποτε σημείο της εφαρμογής να αλλάξει την κατάσταση.

Προτιμούμε:

public OrderStatus Status { get; private set; }

public void Complete()
{
    // Validate business rules
}
Enter fullscreen mode Exit fullscreen mode

Λάθος 2: Ένα Aggregate για ολόκληρο το Domain

Το Aggregate δεν είναι ένας μηχανισμός για να συνδέουμε όλα τα Entities μεταξύ τους.

Μεγάλα Aggregates αυξάνουν το κόστος φόρτωσης και τον κίνδυνο concurrency conflicts.

Λάθος 3: Business Logic στα Application Services

Το Application Layer πρέπει κυρίως να συντονίζει τη λειτουργία.

Δεν πρέπει να περιέχει κανόνες που ανήκουν στην εσωτερική συνέπεια του Aggregate.

Λάθος 4: Repository για κάθε Entity

Δεν χρειάζεται υποχρεωτικά:

OrderRepository
OrderItemRepository
OrderStatusRepository
Enter fullscreen mode Exit fullscreen mode

Αν το OrderItem ανήκει στο Order Aggregate, η διαχείρισή του γίνεται μέσω του Order.

Λάθος 5: Σύγχυση Aggregate και Database Transaction

Ένα Aggregate ορίζει επιχειρησιακά όρια συνέπειας.

Δεν είναι απλώς ένα σύνολο εγγραφών που αποθηκεύονται μαζί.

Το database transaction είναι μηχανισμός υποδομής, ενώ το Aggregate είναι έννοια του Domain.

Λάθος 6: Χρήση Aggregates χωρίς πραγματικούς κανόνες

Δεν χρειάζεται κάθε Entity να αποκτήσει πολύπλοκο Aggregate Root.

Σε ένα απλό CRUD σύστημα, η επιπλέον πολυπλοκότητα μπορεί να μην προσφέρει ουσιαστικό όφελος.


14. Πότε πρέπει να χρησιμοποιούμε Aggregates;

Τα Aggregates είναι ιδιαίτερα χρήσιμα όταν αναπτύσσουμε εφαρμογές με σύνθετη επιχειρησιακή συμπεριφορά.

Για παράδειγμα:

E-commerce: Orders, Shopping Carts, Payments.

Banking: Accounts, Transfers, Loan Applications.

ERP: Invoices, Purchase Orders, Inventory Reservations.

Booking Systems: Reservations, Availability Allocations.

Insurance: Policies, Claims, Coverage Rules.

Σε αυτές τις περιπτώσεις, οι επιχειρησιακοί κανόνες είναι συχνά σημαντικότεροι από τις απλές CRUD λειτουργίες.

Το Aggregate μάς επιτρέπει να εκφράσουμε αυτούς τους κανόνες με σαφή και ελεγχόμενο τρόπο.


15. Τελικές σκέψεις

Το Aggregate είναι μία από τις πιο σημαντικές έννοιες του Domain-Driven Design.

Δεν είναι απλώς ένα design pattern για την οργάνωση Entities.

Είναι ένας τρόπος να σχεδιάζουμε το λογισμικό γύρω από την επιχειρησιακή συνέπεια και συμπεριφορά.

Όταν χρησιμοποιούμε σωστά Aggregates:

  • Οι Business Rules βρίσκονται στο Domain.
  • Τα Entities δεν τροποποιούνται ανεξέλεγκτα.
  • Τα Application Services γίνονται πιο απλά.
  • Τα Unit Tests εστιάζουν στην πραγματική επιχειρησιακή συμπεριφορά.
  • Τα όρια των transactions και της συνέπειας γίνονται πιο ξεκάθαρα.
  • Η εφαρμογή γίνεται ευκολότερη στη συντήρηση και στην εξέλιξη.

Το σημαντικότερο είναι να θυμόμαστε ότι δεν σχεδιάζουμε Aggregates με βάση τη δομή της βάσης δεδομένων, αλλά με βάση τους κανόνες του business.

An Aggregate is not just a collection of entities. It is a consistency boundary that protects business invariants.

Αυτή είναι η ουσία του Aggregate στο Domain-Driven Design.


Further Reading

Για περισσότερες πληροφορίες:


Εσείς χρησιμοποιείτε Aggregates στα .NET projects σας;

Προτιμάτε Rich Domain Models ή πιο απλές CRUD-based αρχιτεκτονικές;

Μοιραστείτε την εμπειρία σας στα σχόλια!

dotnet #csharp #architecture #ddd

Top comments (0)