Μια συνηθισμένη απαίτηση από το frontend είναι να φορτώνουμε τα δεδομένα σταδιακά: πρώτα τη σύνοψη, μετά μια καρτέλα και, όταν χρειαστεί, τις λεπτομέρειές της.

Σε αυτή τη συζήτηση μπορεί να ακουστεί «Δεν θέλω Aggregate στο read, γιατί δεν χρειάζομαι όλα τα δεδομένα μαζί».

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

Τι είναι το Aggregate;

Ας χρησιμοποιήσουμε μια παραγγελία φαγητού:

Order — Παραγγελία, Aggregate Root └── OrderItem — Γραμμή παραγγελίας, π.χ. μία πίτσα └── ItemExtra — Πρόσθετο, π.χ. έξτρα τυρί 

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

Το Order προστατεύει, για παράδειγμα, τον κανόνα:

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

Αυτό κάνει τις τρεις οντότητες υποψήφιες για ένα κοινό Aggregate: υπάρχει κανόνας συνέπειας που τις συνδέει. Η ύπαρξη σχέσεων στη βάση, από μόνη της, δεν αρκεί. Martin Fowler — DDD Aggregate

Πώς λειτουργούν Create, Update και ολοκλήρωση

Το Create είναι write, όπως και το Update, το Delete και οι ενέργειες που αλλάζουν κατάσταση.

Στα writes το Aggregate αξίζει όταν μια αλλαγή πρέπει να διατηρεί κοινούς επιχειρησιακούς κανόνες, όπως η απαγόρευση αλλαγών σε ολοκληρωμένη παραγγελία. Για απλή αποθήκευση ανεξάρτητων δεδομένων χωρίς τέτοιους κανόνες, μια απλούστερη υλοποίηση CRUD μπορεί να αρκεί.

Μπορούμε να έχουμε διαφορετικά endpoints που χρησιμοποιούν το ίδιο Aggregate:

Ενέργεια Endpoint Συμπεριφορά στο Domain
Δημιουργία POST /orders Νέο Order
Προσθήκη γραμμής POST /orders/{orderId}/items order.AddItem(...)
Προσθήκη προσθέτου POST /orders/{orderId}/items/{itemId}/extras order.AddExtra(...)
Ολοκλήρωση POST /orders/{orderId}/complete order.Complete()

Πολλά write endpoints δεν σημαίνουν ανεξάρτητες αλλαγές στα παιδιά του Aggregate. Οι αλλαγές εξακολουθούν να περνούν από το Order.

Create: κατασκευάζουμε έγκυρη αρχική κατάσταση

Μια εντολή δημιουργίας μπορεί να περιέχει εξαρχής γραμμή και πρόσθετο. Ο handler χρησιμοποιεί τιμές που έχει επιβεβαιώσει το backend:

var order = new Order(customerId); // Στο παράδειγμα, η AddItem επιστρέφει το Id της νέας γραμμής. var itemId = order.AddItem("Πίτσα", 1, trustedItemPrice); order.AddExtra(itemId, "Έξτρα τυρί", trustedExtraPrice); dbContext.Orders.Add(order); await dbContext.SaveChangesAsync(ct); 

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

Update: ανακτούμε την παραγγελία και εκτελούμε την αλλαγή

Για προσθήκη προσθέτου σε υπάρχουσα γραμμή, το frontend στέλνει μόνο τη συγκεκριμένη εντολή. Δεν επιστρέφει ολόκληρη την παραγγελία.

Στο μικρό μας παράδειγμα, για απλότητα φορτώνουμε και τα τρία επίπεδα:

var order = await dbContext.Orders .AsTracking() .Include(o => o.Items) .ThenInclude(i => i.Extras) .SingleOrDefaultAsync(o => o.Id == orderId, ct) ?? throw new KeyNotFoundException("Order not found."); order.AddExtra(itemId, extraName, trustedExtraPrice); await dbContext.SaveChangesAsync(ct); 

Η AddExtra του Order ελέγχει ότι η παραγγελία είναι πρόχειρη και η γραμμή υπάρχει. Έπειτα αναθέτει στη γραμμή την ελεγχόμενη προσθήκη του ItemExtra.

Το backend πρέπει να διαθέτει όλη την κατάσταση που απαιτούν οι κανόνες της εντολής. Αυτό δεν συνεπάγεται φόρτωση κάθε σχετιζόμενου αντικειμένου της εφαρμογής. Η ανάκτηση εδώ εξυπηρετεί το write, παρότι ξεκινά με ανάγνωση από τη βάση. Αν χρησιμοποιούμε repository, η φόρτωση μπορεί να βρίσκεται στην υλοποίησή του· custom repository δεν είναι υποχρεωτικό. Microsoft — Persistence layer και Aggregates

Ολοκλήρωση και αποθήκευση ανά καρτέλα

Η order.Complete() ελέγχει ότι η παραγγελία μπορεί να ολοκληρωθεί, π.χ. ότι περιέχει γραμμές. Μετά την ολοκλήρωση, οι μέθοδοι αλλαγής απορρίπτουν τροποποιήσεις και στα βαθύτερα επίπεδα.

Αν αποθηκεύουμε ανά καρτέλα, δημιουργούμε πρώτα ένα Draft και στέλνουμε ξεχωριστές εντολές για γραμμές και πρόσθετα. Κάθε request αποθηκεύεται ξεχωριστά. Αν αποτύχει η προσθήκη προσθέτου, οι προηγούμενες επιτυχημένες ενέργειες δεν αναιρούνται.

Αν απαιτείται δημιουργία όλων ή κανενός, στέλνουμε μία τελική εντολή με όσα χρειάζεται η δημιουργία. Στη συνήθη σχεσιακή ρύθμιση του EF Core, οι αλλαγές μιας SaveChangesAsync αποθηκεύονται ατομικά σε transaction. Αυτό δεν καλύπτει αυτομάτως προηγούμενα requests ούτε τις αρχικές αναγνώσεις του handler. Microsoft — Transactions

Πότε τα reads δεν χρειάζονται Aggregate

Στο ίδιο παράδειγμα, ο χρήστης μπορεί να χρειάζεται:

Ενέργεια στο UI Read endpoint
Άνοιγμα οθόνης GET /orders/{orderId}/summary
Άνοιγμα καρτέλας προϊόντων GET /orders/{orderId}/items
Ανάπτυξη μιας γραμμής GET /orders/{orderId}/items/{itemId}/extras

Έτσι δεν μεταφέρουμε όλα τα πρόσθετα όλων των γραμμών όταν ο χρήστης βλέπει μόνο τη σύνοψη.

Τρία read endpoints μπορούν να εξυπηρετούν δεδομένα ενός Aggregate. Τα όρια του Domain δεν καθορίζονται από τον αριθμό των καρτελών.

Πότε προτιμάμε read χωρίς φόρτωση Aggregate

Για μια απλή σύνοψη μπορούμε να κάνουμε projection σε DTO:

public sealed record OrderSummaryDto(Guid Id, OrderStatus Status); var summary = await dbContext.Orders .Where(o => o.Id == orderId) .Select(o => new OrderSummaryDto(o.Id, o.Status)) .SingleOrDefaultAsync(ct); 

Δεν χρειάζεται Include για τις γραμμές ή τα πρόσθετα. Στο συγκεκριμένο projection επιστρέφονται μόνο τιμές, οπότε δεν δημιουργούνται tracked Domain Entities. Microsoft — Tracking και projections

Αυτή η προσέγγιση ταιριάζει ιδιαίτερα σε λίστες, αναζητήσεις, reports, σελιδοποίηση και καρτέλες που χρειάζονται μικρό μέρος των δεδομένων. Περιορίζουμε τις στήλες και τις εγγραφές που ανακτούμε, αντί να φορτώνουμε πρώτα μεγάλο γράφο και μετά να πετάμε ό,τι περισσεύει. Microsoft — Efficient Querying

Ένα read model μπορεί επίσης να συνδυάζει πληροφορίες από περισσότερα Aggregates, μέσα στα επιτρεπόμενα όρια πρόσβασης της εφαρμογής. Το αποτέλεσμα διαμορφώνεται για την παρουσίαση. Microsoft — Reads/Queries σε CQRS

Πότε είναι λογικό να χρησιμοποιούμε Aggregate και στο read

Η φόρτωση Aggregate για ανάγνωση είναι μια αποδεκτή επιλογή όταν:

  • Το Aggregate είναι μικρό και το κόστος φόρτωσης αποδεκτό.
  • Η λειτουργία χρειάζεται το μεγαλύτερο μέρος της κατάστασής του.
  • Θέλουμε να χρησιμοποιήσουμε υπάρχουσα Domain συμπεριφορά που υπολογίζει αποτέλεσμα χωρίς να αλλάζει κατάσταση.
  • Η επαναχρησιμοποίηση του μοντέλου κρατά την εφαρμογή απλούστερη, χωρίς μετρημένο πρόβλημα απόδοσης.

Αυτά είναι κριτήρια σχεδιασμού, όχι υποχρεωτικός κανόνας του DDD. Ο Fowler επισημαίνει ότι ο διαχωρισμός μοντέλων έχει κόστος και δεν ωφελεί κάθε σύστημα. Martin Fowler — CQRS

Για παράδειγμα, ας υποθέσουμε ότι η οθόνη χρειάζεται ολόκληρη τη μικρή παραγγελία και έναν επιχειρησιακό υπολογισμό που υλοποιείται στην CalculateTotal():

var order = await dbContext.Orders .AsNoTracking() .Include(o => o.Items) .ThenInclude(i => i.Extras) .SingleOrDefaultAsync(o => o.Id == orderId, ct) ?? throw new KeyNotFoundException("Order not found."); var total = order.CalculateTotal(); 

Η CalculateTotal() εδώ είναι καθαρή Domain μέθοδος: διαβάζει την κατάσταση και επιστρέφει αποτέλεσμα χωρίς αλλαγές. Η ύπαρξη τέτοιων query methods είναι συμβατή με ένα μοντέλο που περιέχει συμπεριφορά. Martin Fowler — Command Query Separation

Η επιλογή αυτή μπορεί να αποφεύγει την αντιγραφή σύνθετου υπολογισμού. Αν όμως χρειαζόμαστε μόνο ένα απλό άθροισμα, ένα SQL projection μπορεί να είναι οικονομικότερο. Για μεγάλους όγκους μπορούμε επίσης να επαναχρησιμοποιούμε έναν ανεξάρτητο, καθαρό υπολογιστή Domain κανόνων ή ένα κατάλληλα συντηρημένο read model.

Ο handler χαρτογραφεί τα στοιχεία της παραγγελίας και το αποτέλεσμα του υπολογισμού σε DTO. Το ότι χρησιμοποιήσαμε Aggregate εσωτερικά δεν σημαίνει ότι το επιστρέφουμε αυτούσιο στο API.

Ολόκληρο Aggregate και συγκεντρωτική απάντηση API είναι διαφορετικά

Μπορούμε να έχουμε ένα endpoint λεπτομερειών που επιστρέφει παραγγελία, γραμμές και πρόσθετα με ένα request. Η απάντηση αυτή μπορεί να προκύπτει από DTO projections, χωρίς κατασκευή του DDD Aggregate στη μνήμη.

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

Πρόκειται για πρακτικό συμβιβασμό που αξιολογούμε με το πραγματικό payload, την καθυστέρηση και τη χρήση της οθόνης. Περισσότερα endpoints δεν εγγυώνται καλύτερη απόδοση.

Επιπλέον, ένα HTTP request δεν ισοδυναμεί με ένα SQL query. Το EF Core μπορεί να φορτώνει σχετιζόμενα δεδομένα με ένα ή περισσότερα queries, με διαφορετικά κόστη και απαιτήσεις συνέπειας. Microsoft — Single vs. Split Queries

Τι χρειάζεται προσοχή

Στο παράδειγμά μας, οι Domain υπολογισμοί που χρησιμοποιούν όλες τις γραμμές και τα πρόσθετα απαιτούν πλήρη σχετική κατάσταση. Μία σελίδα γραμμών δεν μπορεί να αντιμετωπίζεται σαν ολόκληρη παραγγελία.

Παράλληλα, χρειάζονται έλεγχοι πρόσβασης σε κάθε endpoint. Αν ένα read εμφανίζει ότι επιτρέπεται μια ενέργεια, το write πρέπει να την ελέγχει ξανά: η κατάσταση μπορεί να έχει αλλάξει στο μεταξύ.

Τα Aggregates πρέπει να παραμένουν μικρά και να περιλαμβάνουν όσα χρειάζονται κοινή επιχειρησιακή συνέπεια. Αν η φόρτωση γίνεται υπερβολικά βαριά, επανεξετάζουμε και τα όριά τους. Microsoft — Tactical DDD

Επίσης, το Aggregate δεν αντικαθιστά την προστασία από ταυτόχρονες αλλαγές. Χρειάζεται κατάλληλη στρατηγική concurrency. Επειδή το rowversion αφορά συγκεκριμένη εγγραφή, ένα τέτοιο token μόνο στο Root δεν καλύπτει αυτομάτως αλλαγές αποκλειστικά σε child rows. Microsoft — Handling Concurrency Conflicts

Ο διαχωρισμός reads και writes μπορεί να ξεκινήσει με ξεχωριστά queries και commands στην ίδια εφαρμογή και βάση. Δεν απαιτεί δεύτερη βάση δεδομένων ή ασύγχρονα μηνύματα. Microsoft — CQRS pattern

Πρακτικός πίνακας επιλογής

Ανάγκη Συνήθης αφετηρία Γιατί
Δημιουργία ή αλλαγή με κοινούς Domain κανόνες Aggregate Root Συγκεντρώνει τους ελέγχους και τις επιτρεπόμενες αλλαγές
Σύνοψη, λίστα ή αναζήτηση DTO projection Ανακτά όσα χρειάζεται η προβολή
Καρτέλες και λεπτομέρειες που ανοίγουν σταδιακά Στοχευμένα read endpoints Αποφεύγει δεδομένα που ίσως δεν ζητηθούν
Μικρό Aggregate και χρήσιμη καθαρή Domain συμπεριφορά Φόρτωση Aggregate και mapping σε DTO Επαναχρησιμοποιεί υπάρχοντες κανόνες με αποδεκτό κόστος
Όλα τα δεδομένα χρειάζονται αμέσως Ενιαίο DTO response Περιορίζει τα requests, χωρίς να επιβάλλει χρήση Aggregate
Απλό CRUD χωρίς ουσιαστικούς κοινούς κανόνες Απλή υλοποίηση Κρατά την πολυπλοκότητα ανάλογη της ανάγκης

Για την παραγγελία τριών επιπέδων, μπορούμε επομένως να φορτώνουμε σύνοψη, γραμμές και πρόσθετα όταν τα ζητά ο χρήστης, διατηρώντας το Order ως σημείο ελέγχου των αλλαγών. Όπου ένα μικρό Aggregate προσφέρει χρήσιμη συμπεριφορά και στο read, μπορούμε να το χρησιμοποιούμε χωρίς να το επιβάλλουμε σε κάθε προβολή.

nikosstit@gmail.com