Όταν αρχίζουμε να ασχολούμαστε με Domain-Driven Design, συναντάμε συχνά την εξής συμβουλή:
Κάνε τους setters private και χρησιμοποίησε μεθόδους για να αλλάζεις την κατάσταση των entities.
Αυτό δημιουργεί μια εύλογη απορία:
«Αφού ένας setter μπορεί να περιέχει κώδικα, γιατί να μη βάλω εκεί τους ελέγχους; Γιατί χρειάζομαι και μεθόδους;»
Η απάντηση είναι ότι μπορούμε να βάλουμε ελέγχους μέσα στον setter. Μπορούμε ακόμη να ελέγξουμε άλλες ιδιότητες του αντικειμένου.
Οι domain methods, όμως, μας βοηθούν να εκφράσουμε καθαρά τις επιχειρησιακές πράξεις που επιτρέπει το μοντέλο μας.
Για να καταλάβουμε τη διαφορά, θα κατασκευάσουμε μια απλή παραγγελία, βήμα βήμα.
1. Πρώτα ορίζουμε τους επιχειρησιακούς κανόνες
Η παραγγελία μας θα έχει:
- Έναν μοναδικό κωδικό.
- Ένα συνολικό ποσό.
- Μια ένδειξη ότι έχει πληρωθεί.
Οι κανόνες του παραδείγματος είναι:
- Το ποσό πρέπει να είναι μεγαλύτερο από μηδέν.
- Κάθε νέα παραγγελία ξεκινά απλήρωτη.
- Το ποσό μπορεί να αλλάξει όσο η παραγγελία είναι απλήρωτη.
- Μετά την πληρωμή, το ποσό δεν επιτρέπεται να αλλάξει.
- Η ίδια παραγγελία δεν επιτρέπεται να πληρωθεί δεύτερη φορά.
Αυτοί είναι οι κανόνες της συγκεκριμένης εφαρμογής. Μια άλλη εφαρμογή μπορεί να επιτρέπει μηδενικές παραγγελίες ή διαφορετικές διαδικασίες πληρωμής.
Στο domain model, οι κανόνες που πρέπει να παραμένουν αληθείς καθώς αλλάζει η κατάσταση του αντικειμένου ονομάζονται invariants.
Για παράδειγμα:
Το ποσό μιας παραγγελίας παραμένει πάντα θετικό.
Η προστασία αυτών των κανόνων αποτελεί βασική ευθύνη του domain model.
Microsoft: Validations in the domain model
2. Η πρώτη υλοποίηση: Public setters παντού
Η πιο απλή μορφή της παραγγελίας είναι η εξής:
public class Order
{
public Guid Id { get; set; }
public decimal TotalAmount { get; set; }
public bool IsPaid { get; set; }
}
Ο κώδικας είναι εύκολος στην ανάγνωση, αλλά επιτρέπει και τα παρακάτω:
var order = new Order();
order.TotalAmount = -100m;
order.IsPaid = true;
order.TotalAmount = 500m;
order.IsPaid = false;
Η κλάση δέχεται:
- Αρνητικό ποσό.
- Αλλαγή ποσού μετά την πληρωμή.
- Επιστροφή από πληρωμένη σε απλήρωτη κατάσταση.
Μπορούμε να βάλουμε τους ελέγχους σε ένα service. Τότε, όμως, η ορθότητα εξαρτάται από το αν κάθε κομμάτι κώδικα θυμάται να χρησιμοποιεί αυτό το service.
Η ίδια η Order δεν προστατεύει τους κανόνες της.
Όταν ένα domain model περιέχει κυρίως δεδομένα και η επιχειρησιακή λογική βρίσκεται έξω από αυτό, το περιγράφουμε ως anemic domain model.
Αυτό δεν σημαίνει ότι κάθε κλάση με public setters είναι προβληματική. Ένα DTO που μεταφέρει δεδομένα από ένα HTTP request μπορεί να έχει public setters χωρίς να χρειάζεται επιχειρησιακή συμπεριφορά.
Εδώ, όμως, σχεδιάζουμε ένα entity που πρέπει να ελέγχει τις αλλαγές της κατάστασής του.
3. Τι προσφέρει το private set;
Ας αλλάξουμε τις δύο ιδιότητες:
public decimal TotalAmount { get; private set; }
public bool IsPaid { get; private set; }
Ο εξωτερικός κώδικας μπορεί να διαβάζει τις τιμές:
Console.WriteLine(order.TotalAmount);
Αλλά δεν μπορεί να τις αλλάζει απευθείας:
order.TotalAmount = 200m; // Δεν κάνει compile
Το private set περιορίζει την πρόσβαση στην ανάθεση.
Δεν προσθέτει από μόνο του κανέναν έλεγχο εγκυρότητας.
Για παράδειγμα, ο παρακάτω constructor εξακολουθεί να δέχεται αρνητικό ποσό:
public Order(decimal totalAmount)
{
TotalAmount = totalAmount;
}
Αν τον καλέσουμε έτσι:
var order = new Order(-100m);
η ανάθεση επιτρέπεται, επειδή γίνεται μέσα στην ίδια την κλάση.
Επομένως:
Το
private setαπαντά στο «ποιος μπορεί να κάνει ανάθεση;». Ο έλεγχος εγκυρότητας απαντά στο «ποια τιμή επιτρέπεται;».
Επίσης, αν θέλουμε να επιτρέπουμε αλλαγές μετά τη δημιουργία της παραγγελίας, χρειαζόμαστε έναν προσβάσιμο τρόπο να τις ζητά ο εξωτερικός κώδικας.
4. Μπορούμε να βάλουμε validation μέσα στον setter;
Ναι. Ένας setter μπορεί να περιέχει κανονικό κώδικα:
private decimal _totalAmount;
public decimal TotalAmount
{
get => _totalAmount;
private set
{
if (value <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(value),
"Το ποσό πρέπει να είναι μεγαλύτερο από μηδέν.");
}
_totalAmount = value;
}
}
Το value είναι η νέα τιμή που προσπαθούμε να αναθέσουμε.
Όταν η κλάση εκτελεί:
TotalAmount = 150m;
ο setter εκτελείται με value = 150m.
Αν εκτελέσει:
TotalAmount = -10m;
ο setter πετάει exception πριν αλλάξει το _totalAmount.
Η C# υποστηρίζει κανονικά validation μέσα σε properties. Το private αλλάζει την προσβασιμότητα του setter, όχι το τι κώδικα μπορεί να περιέχει.
Μπορεί ο setter να ελέγξει και το IsPaid;
Επίσης ναι:
private set
{
if (IsPaid)
{
throw new InvalidOperationException(
"Δεν αλλάζει το ποσό πληρωμένης παραγγελίας.");
}
if (value <= 0)
{
throw new ArgumentOutOfRangeException(nameof(value));
}
_totalAmount = value;
}
Ο setter έχει πρόσβαση στα υπόλοιπα μέλη του αντικειμένου.
Άρα, για το συγκεκριμένο παράδειγμα, θα ήταν λανθασμένο να πούμε ότι ο κανόνας «δεν αλλάζει το ποσό μετά την πληρωμή» δεν μπορεί να υλοποιηθεί σε setter.
Μπορεί.
Το ερώτημα είναι πλέον ποιο API εκφράζει καθαρότερα τη συμπεριφορά του αντικειμένου.
5. Τότε γιατί χρησιμοποιούμε domain methods;
Αν ο setter είναι public, μπορούμε να γράψουμε:
order.TotalAmount = 150m;
και να εκτελεστούν οι έλεγχοι που έχουμε ορίσει.
Αν είναι private, η παραπάνω ανάθεση δεν επιτρέπεται έξω από την κλάση. Για να ζητήσουμε αλλαγή, μπορούμε να προσφέρουμε μια μέθοδο:
order.ChangeTotal(150m);
Η διαφορά φαίνεται καθαρότερα στην πληρωμή.
Συγκρίνουμε:
order.IsPaid = true;
με:
order.Pay();
Η πρώτη γραμμή αναθέτει μια τιμή σε μια ιδιότητα.
Η δεύτερη ζητά από το αντικείμενο να εκτελέσει μια συγκεκριμένη επιχειρησιακή πράξη.
Ένας public setter στο IsPaid πρέπει επίσης να ορίζει τι σημαίνουν οι αναθέσεις:
order.IsPaid = false;
και:
order.IsPaid = true; // Ενώ είναι ήδη πληρωμένη
Τις αγνοούμε; Πετάμε exception; Επιτρέπουμε επιστροφή στην προηγούμενη κατάσταση;
Όλα αυτά μπορούν να υλοποιηθούν μέσα στον setter.
Με μεθόδους, όμως, μπορούμε να εκθέσουμε ακριβώς τις πράξεις που υποστηρίζει το domain:
order.Pay();
Και, αν προκύψει σχετική απαίτηση, μια ξεχωριστή πράξη:
order.Refund();
Η επιστροφή χρημάτων μπορεί να έχει διαφορετικούς κανόνες από την πληρωμή. Δεν χρειάζεται να κρύβουμε και τις δύο λειτουργίες πίσω από μια ιδιότητα τύπου bool.
Οι μέθοδοι βοηθούν το API του entity να μιλά με τους όρους της επιχείρησης.
6. Πλήρες παράδειγμα: Private setters και domain methods μαζί
Οι δύο προσεγγίσεις μπορούν να συνεργαστούν.
Στην παρακάτω υλοποίηση:
- Ο constructor δημιουργεί μια έγκυρη παραγγελία.
- Ο setter ελέγχει ότι το ποσό είναι θετικό.
- Η
ChangeTotal()ελέγχει αν επιτρέπεται η αλλαγή. - Η
Pay()ελέγχει αν επιτρέπεται η πληρωμή.
Στο παράδειγμα, η Pay() καταχωρίζει την παραγγελία ως πληρωμένη. Θεωρούμε ότι η επιτυχής χρέωση έχει ήδη επιβεβαιωθεί από την εφαρμογή. Η μέθοδος δεν επικοινωνεί με τράπεζα ή πάροχο πληρωμών.
using System;
public sealed class Order
{
private decimal _totalAmount;
public Guid Id { get; private set; }
public decimal TotalAmount
{
get => _totalAmount;
private set
{
if (value <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(value),
"Το ποσό πρέπει να είναι μεγαλύτερο από μηδέν.");
}
_totalAmount = value;
}
}
public bool IsPaid { get; private set; }
public Order(decimal totalAmount)
{
Id = Guid.NewGuid();
// Η ανάθεση περνά από τον setter και ελέγχεται.
TotalAmount = totalAmount;
IsPaid = false;
}
public void ChangeTotal(decimal newTotalAmount)
{
if (IsPaid)
{
throw new InvalidOperationException(
"Δεν αλλάζει το ποσό πληρωμένης παραγγελίας.");
}
// Ο setter ελέγχει τη νέα τιμή.
TotalAmount = newTotalAmount;
}
public void Pay()
{
if (IsPaid)
{
throw new InvalidOperationException(
"Η παραγγελία έχει ήδη πληρωθεί.");
}
IsPaid = true;
}
}
Ο έλεγχος του θετικού ποσού βρίσκεται στον setter, ώστε να χρησιμοποιείται τόσο από τον constructor όσο και από τη ChangeTotal().
Ο έλεγχος για το αν επιτρέπεται αλλαγή ποσού βρίσκεται στη μέθοδο που εκφράζει αυτή την πράξη.
Αυτός είναι ένας έγκυρος σχεδιασμός. Μια άλλη επιλογή θα ήταν να τοποθετήσουμε τον κοινό έλεγχο του ποσού σε μια private βοηθητική μέθοδο.
Η σημαντική απαίτηση είναι να ελέγχονται όλες οι δημόσιες διαδρομές δημιουργίας και αλλαγής του αντικειμένου.
7. Πώς χρησιμοποιείται η παραγγελία;
Η επιτρεπόμενη ροή είναι απλή:
var order = new Order(100m);
order.ChangeTotal(120m);
order.Pay();
Console.WriteLine(order.TotalAmount); // 120
Console.WriteLine(order.IsPaid); // True
Δημιουργία με μη έγκυρο ποσό
var order = new Order(-100m);
// ArgumentOutOfRangeException
Αλλαγή σε μη έγκυρο ποσό
var order = new Order(100m);
order.ChangeTotal(0m);
// ArgumentOutOfRangeException
Το ποσό παραμένει 100m, επειδή ο έλεγχος εκτελείται πριν από την αλλαγή του πεδίου.
Αλλαγή ποσού μετά την πληρωμή
var order = new Order(100m);
order.Pay();
order.ChangeTotal(150m);
// InvalidOperationException
Δεύτερη πληρωμή
var order = new Order(100m);
order.Pay();
order.Pay();
// InvalidOperationException
Ο εξωτερικός κώδικας δεν χρειάζεται να επαναλαμβάνει αυτούς τους ελέγχους πριν από κάθε κλήση.
Το entity αποφασίζει αν μπορεί να εκτελέσει την πράξη.
8. Μια μέθοδος δεν κάνει αυτόματα το μοντέλο behavior-rich
Ας υποθέσουμε ότι γράφαμε:
public void SetPaid(bool value)
{
IsPaid = value;
}
Ο setter είναι private, αλλά ο εξωτερικός κώδικας μπορεί ακόμη να γράψει:
order.SetPaid(false);
Δεν έχουμε προστατεύσει τον κανόνα της παραγγελίας. Απλώς μεταφέραμε την ανεξέλεγκτη ανάθεση πίσω από μια δημόσια μέθοδο.
Ούτε η παρακάτω υλοποίηση αρκεί για τους κανόνες μας:
public void ChangeTotal(decimal amount)
{
TotalAmount = amount;
}
Ο setter θα απορρίψει ένα αρνητικό ποσό, αλλά η μέθοδος θα επιτρέψει αλλαγή ποσού μετά την πληρωμή.
Μια απλή μέθοδος ανάθεσης δεν είναι από μόνη της λάθος. Απλώς δεν υλοποιεί κανόνες που δεν έχουμε γράψει.
Το μοντέλο γίνεται behavior-rich όταν οι λειτουργίες του εκφράζουν και προστατεύουν την επιχειρησιακή συμπεριφορά. Ο αριθμός των μεθόδων δεν αποτελεί κριτήριο.
9. Ποιος είναι ο ρόλος του application service;
Το application service οργανώνει τη ροή της εφαρμογής.
Για παράδειγμα, μετά την επιβεβαίωση επιτυχούς χρέωσης, η ενδεικτική ροή μπορεί να είναι:
var order = await repository.GetByIdAsync(orderId);
order.Pay();
await repository.SaveChangesAsync();
Το repository ανακτά και αποθηκεύει δεδομένα.
Το entity ελέγχει αν επιτρέπεται η μετάβαση στην πληρωμένη κατάσταση.
Το application service συνεχίζει να έχει ευθύνες, όπως:
- Τον συντονισμό του use case.
- Την επικοινωνία με εξωτερικά συστήματα.
- Την ανάκτηση των απαραίτητων δεδομένων.
- Τον συντονισμό της αποθήκευσης.
Δεν χρειάζεται όμως να αντιγράφει παντού τον κανόνα «η παραγγελία δεν πληρώνεται δεύτερη φορά».
10. Πώς ελέγχουμε τη συμπεριφορά με tests;
Οι κανόνες της Order μπορούν να δοκιμαστούν απευθείας, χωρίς database ή repository.
Για παράδειγμα, με xUnit:
using System;
using Xunit;
public class OrderTests
{
[Fact]
public void Order_requires_positive_total()
{
Assert.Throws<ArgumentOutOfRangeException>(
() => new Order(0m));
}
[Fact]
public void Paid_order_cannot_change_total()
{
var order = new Order(100m);
order.Pay();
Assert.Throws<InvalidOperationException>(
() => order.ChangeTotal(150m));
Assert.Equal(100m, order.TotalAmount);
}
[Fact]
public void Order_cannot_be_paid_twice()
{
var order = new Order(100m);
order.Pay();
Assert.Throws<InvalidOperationException>(
() => order.Pay());
}
}
Αυτά τα tests εξετάζουν τη συμπεριφορά του entity.
Ξεχωριστά tests μπορούν να ελέγξουν την αποθήκευση και τη συνολική ροή της εφαρμογής.
11. Πότε χρησιμοποιούμε setter και πότε μέθοδο;
Δεν χρειάζεται κάθε αλλαγή ιδιότητας να μετατραπεί σε domain method.
Ένας setter μπορεί να είναι επαρκής όταν η λειτουργία περιγράφεται φυσικά ως ανάθεση μιας τιμής και οι κανόνες της παραμένουν απλοί.
Μια μέθοδος γίνεται ιδιαίτερα χρήσιμη όταν:
- Η αλλαγή αντιστοιχεί σε συγκεκριμένη επιχειρησιακή πράξη.
- Υπάρχουν προϋποθέσεις για την εκτέλεσή της.
- Χρειάζονται περισσότερες παράμετροι.
- Αλλάζουν συντονισμένα περισσότερα μέρη της κατάστασης.
- Θέλουμε να εκθέσουμε συγκεκριμένες επιτρεπόμενες μεταβάσεις.
| Ανάγκη | Πιθανή επιλογή |
|---|---|
| Μεταφορά δεδομένων σε DTO | Public setters ή init
|
| Απλή ανάθεση με έλεγχο εγκυρότητας | Setter με validation |
| Ανάθεση μόνο από το εσωτερικό του entity | Private setter |
| Πληρωμή, ακύρωση ή αποστολή παραγγελίας | Domain method |
| Επιχειρησιακή πράξη με πολλές παραμέτρους | Domain method |
| Κοινός έλεγχος που χρησιμοποιείται σε πολλές λειτουργίες | Private βοηθητική μέθοδος ή κατάλληλος setter |
Ένας setter μπορεί τεχνικά να αλλάζει και άλλες ιδιότητες.
Όσο όμως μια ανάθεση αποκτά περισσότερες επιχειρησιακές συνέπειες, μια ονομασμένη μέθοδος συνήθως βοηθά τον αναγνώστη να καταλάβει τι ακριβώς ζητά και τι θα συμβεί.
12. Και το Entity Framework Core;
Το EF Core υποστηρίζει properties με private setters.
Επομένως, η χρήση του δεν μας υποχρεώνει να εκθέτουμε δημόσιες αναθέσεις για κάθε ιδιότητα του domain model.
Η χαρτογράφηση παραμένει ξεχωριστό ζήτημα που ελέγχουμε για το συγκεκριμένο μοντέλο μας.
Microsoft: Entity types with constructors
Στην παραγγελία του παραδείγματος, ο εξωτερικός κώδικας μπορεί να διαβάζει το IsPaid, ενώ η αλλαγή του περνά από την Pay().
Μπορεί επίσης να διαβάζει το TotalAmount, ενώ η αλλαγή του περνά από την ChangeTotal().
Ο private setter περιορίζει την άμεση πρόσβαση. Ο κώδικας του setter ελέγχει ό,τι του έχουμε αναθέσει. Η domain method εκφράζει μια διαθέσιμη πράξη και επιβάλλει τους κανόνες της.
Όταν σχεδιάζουμε ένα entity, το χρήσιμο ερώτημα είναι:
Μπορεί ο κώδικας που χρησιμοποιεί αυτό το αντικείμενο να παραβιάσει τους επιχειρησιακούς κανόνες μέσα από τις δημόσιες λειτουργίες του;
Η απάντηση σε αυτό το ερώτημα μας καθοδηγεί στην επιλογή του API και στην τοποθέτηση των ελέγχων.
Top comments (0)