Υπότιτλος: Clean Architecture, Dependency Inversion, αρχιτεκτονικά όρια και οι πραγματικοί συμβιβασμοί πίσω από τα custom repositories.
Εισαγωγή
Κάθε τόσο επανέρχεται στις κοινότητες των .NET developers η ίδια συζήτηση:
«Γιατί χρησιμοποιείς Repository Pattern; Το Entity Framework Core υλοποιεί ήδη Repository και Unit of Work. Η επιπλέον αφαίρεση είναι περιττή και ξεπερασμένη».
Με την πρώτη ματιά, το επιχείρημα ακούγεται λογικό. Το Entity Framework Core παρέχει το DbSet<T> για την εργασία με οντότητες και το DbContext για την παρακολούθηση αλλαγών και την αποθήκευσή τους. Γιατί, λοιπόν, να προσθέσουμε επιπλέον interfaces και κλάσεις που φαίνεται να κάνουν ακριβώς το ίδιο πράγμα;
Ωστόσο, αυτή η συζήτηση συχνά συγχέει δύο διαφορετικά ερωτήματα:
- Παρέχει το Entity Framework Core λειτουργίες αντίστοιχες με Repository και Unit of Work;
- Σημαίνει αυτό ότι ένα custom Repository δεν έχει αρχιτεκτονική αξία;
Στο πρώτο ερώτημα η απάντηση είναι ναι. Στο δεύτερο, όχι.
Η ουσιαστική συζήτηση δεν αφορά το αν ένα pattern είναι παλιό ή σύγχρονο. Αφορά την κατεύθυνση των εξαρτήσεων, τα αρχιτεκτονικά όρια, τη συντηρησιμότητα, τη δυνατότητα δοκιμών και τους συμβιβασμούς που αποδεχόμαστε συνειδητά όταν σχεδιάζουμε μια εφαρμογή.
Αν επιλέγουμε Clean Architecture, πρέπει να γνωρίζουμε γιατί την επιλέγουμε εξαρχής.
1. Η Clean Architecture αφορά την κατεύθυνση των εξαρτήσεων
Η Clean Architecture δεν είναι απλώς μια δομή φακέλων με Domain, Application, Infrastructure και Presentation.
Σκοπός της είναι να διατηρεί τους επιχειρησιακούς κανόνες ανεξάρτητους από τις εξωτερικές τεχνικές λεπτομέρειες, όπως οι βάσεις δεδομένων, τα frameworks, τα user interfaces και οι υπηρεσίες υποδομής.
Ο Robert C. Martin περιγράφει αυτή την προσέγγιση στο άρθρο του The Clean Architecture.
Ένας από τους σημαντικότερους κανόνες είναι ο Dependency Rule:
Οι εξαρτήσεις του πηγαίου κώδικα πρέπει να κατευθύνονται προς τα μέσα, προς τις πολιτικές υψηλότερου επιπέδου.
Σε μια τυπική λύση .NET με Clean Architecture, οι εξαρτήσεις των projects μπορούν να αποτυπωθούν ως εξής:
flowchart TD
Presentation --> Application
Application --> Domain
Infrastructure --> Application
Infrastructure --> Domain
Το διάγραμμα παρουσιάζει τις εξαρτήσεις των projects και του πηγαίου κώδικα, όχι την κατεύθυνση των κλήσεων κατά την εκτέλεση.
Οι ευθύνες των layers είναι οι εξής:
- Domain: Entities, Value Objects, domain services και επιχειρησιακοί κανόνες.
- Application: Use cases, commands, queries, handlers και συμβόλαια που απαιτούν οι περιπτώσεις χρήσης.
- Infrastructure: Entity Framework Core, πρόσβαση στη βάση δεδομένων, εξωτερικές υπηρεσίες και υλοποιήσεις των persistence contracts.
- Presentation: Controllers, endpoints και το σημείο εισόδου της εφαρμογής.
Το Domain δεν πρέπει να εξαρτάται από το Application ή το Infrastructure. Το Application δεν πρέπει να εξαρτάται από τις υλοποιήσεις του Infrastructure. Αντίθετα, το Infrastructure μπορεί να εξαρτάται από abstractions που ορίζονται στα εσωτερικά layers.
Εδώ ακριβώς αποκτά σημασία το Repository Pattern.
2. Dependency Inversion Principle: Το σημαντικότερο σημείο
Το Dependency Inversion Principle (DIP) είναι μία από τις αρχές SOLID.
Σύμφωνα με αυτή, τα high-level modules δεν πρέπει να εξαρτώνται άμεσα από low-level modules. Και τα δύο πρέπει να εξαρτώνται από abstractions. Οι αφαιρέσεις δεν πρέπει να εξαρτώνται από τις λεπτομέρειες υλοποίησης· οι υλοποιήσεις πρέπει να εξαρτώνται από τις αφαιρέσεις.
Ας εξετάσουμε έναν handler που εξαρτάται άμεσα από το Entity Framework Core:
public sealed class CreateUserHandler
{
private readonly AppDbContext _context;
public CreateUserHandler(AppDbContext context)
{
_context = context;
}
public async Task HandleAsync(
User user,
CancellationToken cancellationToken)
{
_context.Users.Add(user);
await _context.SaveChangesAsync(
cancellationToken);
}
}
Ο κώδικας είναι λειτουργικός. Δεν είναι εγγενώς λανθασμένος.
Ωστόσο, το application use case εξαρτάται άμεσα από το AppDbContext, το οποίο αποτελεί μέρος της υλοποίησης του persistence layer.
Επομένως, το Application project χρειάζεται αναφορά στο project ή assembly που περιέχει το συγκεκριμένο context και συνδέεται με την προσέγγιση persistence του EF Core.
Αν ο αρχιτεκτονικός μας στόχος είναι να διατηρήσουμε το Application ανεξάρτητο από την τεχνολογία αποθήκευσης, αυτή η εξάρτηση αποτελεί έναν σημαντικό συμβιβασμό.
Μπορούμε να εισαγάγουμε μια αφαίρεση:
public interface IUserRepository
{
void Add(User user);
}
Το interface βρίσκεται στο Application, επειδή εκεί βρίσκεται το use case που το χρειάζεται.
Η υλοποίηση βρίσκεται στο Infrastructure:
public sealed class UserRepository : IUserRepository
{
private readonly AppDbContext _context;
public UserRepository(AppDbContext context)
{
_context = context;
}
public void Add(User user)
{
_context.Users.Add(user);
}
}
Ο handler εξαρτάται πλέον από την αφαίρεση:
public sealed class CreateUserHandler
{
private readonly IUserRepository _repository;
public CreateUserHandler(
IUserRepository repository)
{
_repository = repository;
}
public Task HandleAsync(
User user,
CancellationToken cancellationToken)
{
_repository.Add(user);
return Task.CompletedTask;
}
}
Το παράδειγμα παραλείπει σκόπιμα τον συντονισμό της αποθήκευσης. Αυτός πρέπει να αντιμετωπίζεται από την επιλεγμένη στρατηγική Unit of Work ή transactions.
Το σημαντικό σημείο είναι η σχέση των εξαρτήσεων:
classDiagram
class CreateUserHandler {
+HandleAsync()
}
class IUserRepository {
<<interface>>
+Add(User)
}
class UserRepository {
+Add(User)
}
CreateUserHandler --> IUserRepository : εξαρτάται από
UserRepository ..|> IUserRepository : υλοποιεί
Το Application ορίζει το συμβόλαιο που χρειάζεται. Το Infrastructure το υλοποιεί χρησιμοποιώντας EF Core.
Αυτή είναι εφαρμογή του Dependency Inversion στο persistence layer.
Το Repository Pattern δεν είναι ο μοναδικός τρόπος εφαρμογής του DIP. Είναι, όμως, ένας καθιερωμένος τρόπος δημιουργίας ενός σαφούς ορίου μεταξύ της εφαρμογής και της πρόσβασης στα δεδομένα.
3. «Μα το Entity Framework Core υλοποιεί ήδη Repository και Unit of Work»
Αυτό είναι το ισχυρότερο επιχείρημα εναντίον των custom repositories και αξίζει να εξεταστεί δίκαια.
Το Entity Framework Core παρέχει ήδη abstractions με συμπεριφορά αντίστοιχη του Repository και του Unit of Work.
Για παράδειγμα:
var user = await context.Users
.FirstOrDefaultAsync(
x => x.Id == userId,
cancellationToken);
Το DbSet<T> παρέχει πρόσβαση σε σύνολα οντοτήτων και υποστηρίζει την ανάκτηση και τροποποίησή τους.
Το DbContext παρακολουθεί τις αλλαγές και συντονίζει την αποθήκευσή τους μέσω του SaveChangesAsync().
Για πολλές εφαρμογές, αυτό είναι απολύτως επαρκές.
Ένα custom Repository που απλώς περιτυλίγει κάθε μέθοδο του DbSet<T> μπορεί να προσθέτει boilerplate χωρίς ουσιαστικό διαχωρισμό.
Για παράδειγμα:
public interface IRepository<T>
where T : class
{
Task<T?> GetByIdAsync(Guid id);
Task AddAsync(T entity);
void Update(T entity);
void Delete(T entity);
}
Αυτό το interface δεν είναι αυτομάτως κακό. Αν, όμως, απλώς αναπαράγει τις λειτουργίες του EF Core και εκθέτει παντού IQueryable<T>, ενδέχεται να προσφέρει ελάχιστη προστασία από την εξάρτηση της εφαρμογής από το persistence layer.
Το σωστό συμπέρασμα δεν είναι ότι τα repositories έχουν ξεπεραστεί.
Είναι ότι ένα Repository πρέπει να δικαιολογεί την ύπαρξή του μέσω του αρχιτεκτονικού ορίου ή της εξειδικευμένης συμπεριφοράς που προσφέρει.
Η Microsoft εξετάζει τόσο την προσέγγιση με repositories όσο και την απευθείας χρήση του DbContext στην τεκμηρίωση Implementing the persistence layer with Entity Framework Core.
Η τεκμηρίωση αναγνωρίζει ότι το EF Core παρέχει ήδη συμπεριφορά Repository και Unit of Work, ενώ παράλληλα εξηγεί γιατί τα custom repositories μπορούν να παραμένουν χρήσιμα, ιδιαίτερα σε πιο σύνθετες εφαρμογές.
4. Generic Repository ή Domain-Specific Repository;
Δεν προσφέρουν όλα τα repositories τον ίδιο βαθμό αφαίρεσης.
Ένα generic repository μπορεί να παρέχει τυπικές λειτουργίες CRUD:
public interface IRepository<T>
where T : class
{
Task<T?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken);
Task AddAsync(
T entity,
CancellationToken cancellationToken);
void Remove(T entity);
}
Αυτό μπορεί να είναι χρήσιμο όταν οι λειτουργίες είναι πράγματι κοινές μεταξύ των οντοτήτων.
Ωστόσο, τα επιχειρησιακά domains σπάνια περιορίζονται σε απλές CRUD λειτουργίες.
Ας εξετάσουμε ένα σύστημα διαχείρισης χρηστών.
Μια λειτουργία μπορεί να χρειάζεται να ελέγξει αν υπάρχει ήδη ένα email, να ανακτήσει έναν χρήστη μαζί με τα σχετικά permissions ή να φορτώσει ένα aggregate για μια επιχειρησιακή ενέργεια.
Ένα domain-specific repository μπορεί να εκφράζει απευθείας αυτές τις ανάγκες:
public interface IUserRepository
{
Task<User?> GetByIdAsync(
Guid userId,
CancellationToken cancellationToken);
Task<bool> EmailExistsAsync(
string email,
CancellationToken cancellationToken);
void Add(User user);
}
Η υλοποίηση μπορεί να χρησιμοποιεί τα καταλληλότερα queries του EF Core:
public sealed class UserRepository : IUserRepository
{
private readonly AppDbContext _context;
public UserRepository(AppDbContext context)
{
_context = context;
}
public Task<User?> GetByIdAsync(
Guid userId,
CancellationToken cancellationToken)
{
return _context.Users
.FirstOrDefaultAsync(
x => x.Id == userId,
cancellationToken);
}
public Task<bool> EmailExistsAsync(
string email,
CancellationToken cancellationToken)
{
return _context.Users
.AnyAsync(
x => x.Email == email,
cancellationToken);
}
public void Add(User user)
{
_context.Users.Add(user);
}
}
Το Application εκφράζει τι χρειάζεται, χωρίς να γνωρίζει πώς υλοποιείται το query στη βάση δεδομένων.
Υπάρχει και μια δεύτερη σημαντική διάκριση: δεν είναι απαραίτητο να δημιουργούμε διαφορετικό Repository για κάθε use case.
Τα CreateUser, UpdateUser και GetUser μπορούν να χρησιμοποιούν το ίδιο IUserRepository, εφόσον εξυπηρετεί τις ανάγκες τους.
Σε εφαρμογές CQRS, οι command handlers μπορούν να χρησιμοποιούν repositories για την ανάκτηση και τροποποίηση aggregates, ενώ οι read handlers μπορούν να χρησιμοποιούν απευθείας projections του EF Core σε DTOs, εφόσον η αρχιτεκτονική επιτρέπει συνειδητά αυτή την εξάρτηση.
Στόχος δεν είναι να μεγιστοποιήσουμε τον αριθμό των repositories. Στόχος είναι να δημιουργήσουμε χρήσιμα και σαφή όρια.
5. Πού πρέπει να βρίσκονται τα Repository Interfaces;
Αυτό το ερώτημα συζητείται συχνά στις υλοποιήσεις Clean Architecture.
Δεν υπάρχει καθολικός κανόνας ότι κάθε repository interface πρέπει να ανήκει στο Domain project.
Η σωστή θέση εξαρτάται από την ευθύνη που εκφράζει το interface.
Επιλογή Α: Interfaces στο Application
Application/
Users/
IUserRepository.cs
CreateUser/
CreateUserCommand.cs
CreateUserHandler.cs
Infrastructure/
Persistence/
UserRepository.cs
AppDbContext.cs
Αυτός είναι ένας έγκυρος σχεδιασμός όταν το repository εκφράζει ένα συμβόλαιο πρόσβασης στα δεδομένα που απαιτούν τα application use cases.
Το Application ορίζει το συμβόλαιο και το Infrastructure παρέχει την υλοποίησή του.
Η προσέγγιση αυτή είναι ιδιαίτερα πρακτική σε εφαρμογές που οργανώνουν τη λειτουργικότητά τους ανά use case ή feature.
Επιλογή Β: Interfaces στο Domain
Domain/
Users/
User.cs
IUserRepository.cs
Infrastructure/
Persistence/
UserRepository.cs
Και αυτή είναι έγκυρη επιλογή όταν το συμβόλαιο του repository θεωρείται μέρος του domain model, συχνά σε μια προσέγγιση Domain-Driven Design που οργανώνεται γύρω από aggregate roots.
Η ουσιαστική διάκριση αφορά την ιδιοκτησία του συμβολαίου: ποιο layer χρειάζεται την αφαίρεση και ποιο πρέπει να την ορίζει;
Η θέση του interface πρέπει να αντικατοπτρίζει αυτή την απόφαση, αντί να ακολουθεί έναν κανόνα σύμφωνα με τον οποίο όλα τα repositories πρέπει να βρίσκονται στο ίδιο project ανεξάρτητα από τον σκοπό τους.
6. Επιβάλλει η Clean Architecture τη χρήση custom repositories;
Όχι. Η Clean Architecture απαιτεί να τηρούνται τα όρια των εξαρτήσεων· δεν επιβάλλει μία συγκεκριμένη υλοποίηση Repository.
Αυτή η διάκριση είναι σημαντική.
Ας εξετάσουμε δύο διαφορετικές αρχιτεκτονικές.
Αρχιτεκτονική Α: Απευθείας χρήση EF Core
Application
└── CreateUserHandler
└── AppDbContext
Infrastructure
└── EF Core configuration
Αυτή μπορεί να είναι λογική επιλογή για μια σχετικά απλή εφαρμογή, ιδιαίτερα όταν η ομάδα αποδέχεται συνειδητά την εξάρτηση του Application από το EF Core.
Μειώνει τον αριθμό των abstractions και των κλάσεων.
Ωστόσο, το Application συνδέεται πλέον με το persistence framework.
Αρχιτεκτονική Β: Custom Repository abstraction
Application
├── CreateUserHandler
└── IUserRepository
Infrastructure
├── UserRepository
└── AppDbContext
Αυτός ο σχεδιασμός εισάγει επιπλέον κώδικα και ένα επίπεδο έμμεσης πρόσβασης.
Ανταλλάσσει αυτή την πολυπλοκότητα με ένα συγκεκριμένο όφελος: το Application εξαρτάται από ένα συμβόλαιο που μπορεί να υλοποιηθεί ανεξάρτητα από το EF Core.
Το αν αυτή η επιπλέον απομόνωση αξίζει το κόστος εξαρτάται από τις απαιτήσεις του project.
Αν ο δηλωμένος στόχος είναι να παραμείνει το Application ανεξάρτητο από την τεχνολογία persistence, η δεύτερη αρχιτεκτονική εκφράζει πιο άμεσα αυτόν τον στόχο.
Αν ο στόχος είναι η ελαχιστοποίηση των abstractions σε μια μικρή εφαρμογή, η πρώτη επιλογή μπορεί να είναι προτιμότερη.
Πρόκειται για διαφορετικούς συμβιβασμούς, όχι για απόδειξη ότι ένα pattern έχει ξεπεραστεί.
7. Testing: Αρχιτεκτονικό όφελος, όχι μαγική λύση
Τα custom repositories μπορούν να διευκολύνουν τις δοκιμές των application use cases χωρίς σύνδεση σε production database.
Για παράδειγμα, ένας handler που εξαρτάται από το IUserRepository μπορεί να δοκιμαστεί με μια fake υλοποίηση ή ένα mock.
public sealed class FakeUserRepository : IUserRepository
{
private readonly List<User> _users = [];
public void Add(User user)
{
_users.Add(user);
}
public Task<User?> GetByIdAsync(
Guid userId,
CancellationToken cancellationToken)
{
return Task.FromResult(
_users.FirstOrDefault(x => x.Id == userId));
}
public Task<bool> EmailExistsAsync(
string email,
CancellationToken cancellationToken)
{
return Task.FromResult(
_users.Any(x => x.Email == email));
}
}
Έτσι, μπορούμε να ελέγξουμε τη συμπεριφορά του Application ανεξάρτητα από την υποδομή της βάσης δεδομένων.
Ωστόσο, ένα fake repository δεν επιβεβαιώνει ότι η πραγματική υλοποίηση EF Core παράγει το σωστό query ή συμπεριφέρεται σωστά με τον επιλεγμένο database provider.
Για αυτές τις περιπτώσεις εξακολουθούν να απαιτούνται integration tests.
Η Microsoft εξηγεί αυτούς τους συμβιβασμούς στην τεκμηρίωσή της για την επιλογή στρατηγικής testing για το EF Core και για testing χωρίς την production database.
Το συμπέρασμα είναι απλό: τα repositories μπορούν να βελτιώσουν την απομόνωση των tests, αλλά δεν καταργούν την ανάγκη ελέγχου της συμπεριφοράς του persistence layer.
8. Τι γίνεται με το Unit of Work και τα transactions;
Οι συζητήσεις για τα repositories συχνά οδηγούν σε μια ακόμη παρανόηση: ότι η προσθήκη ενός Repository λύνει αυτομάτως τη διαχείριση συναλλαγών.
Δεν ισχύει κάτι τέτοιο.
Το DbContext του EF Core παρέχει ήδη συμπεριφορά Unit of Work. Το SaveChangesAsync() αποθηκεύει τις αλλαγές που παρακολουθεί το context και, στις συνήθεις περιπτώσεις όπου υποστηρίζονται συναλλαγές από τον provider, τις εκτελεί ατομικά μέσα σε transaction.
Ένα custom repository μπορεί να προσθέτει οντότητες στο context χωρίς να κάνει άμεση αποθήκευση:
repository.Add(user);
repository.Add(profile);
await unitOfWork.SaveChangesAsync(cancellationToken);
Αυτό μπορεί να κάνει πιο σαφή τη διαχείριση της αποθήκευσης και να συντονίσει πολλαπλά repositories στο πλαίσιο ενός Unit of Work.
Ωστόσο, ένα ξεχωριστό IUnitOfWork δεν είναι υποχρεωτικό απλώς και μόνο επειδή υπάρχουν repositories. Η αξία του εξαρτάται από το αν εκφράζει ένα χρήσιμο συμβόλαιο και από τον τρόπο που η εφαρμογή διαχειρίζεται την αποθήκευση και τα transactions.
Το σημαντικό είναι να γνωρίζουμε πού γίνεται το commit των αλλαγών και αν το συγκεκριμένο use case απαιτεί ατομικότητα.
9. Η ευθύνη του Architect: Να εξηγεί τους συμβιβασμούς
Ένας senior developer ή software architect πρέπει να μπορεί να εξηγήσει τις συνέπειες μιας σχεδιαστικής απόφασης και όχι απλώς να χαρακτηρίζει ένα pattern παλιό ή σύγχρονο.
Μια ουσιαστική αρχιτεκτονική αξιολόγηση πρέπει να εξετάζει:
- Εξαρτάται το Application από υλοποιήσεις του Infrastructure;
- Εκφράζουν τα interfaces πραγματικές ανάγκες του Application ή του Domain;
- Κρύβουν τα repositories τις λεπτομέρειες του persistence ή απλώς αναπαράγουν το
DbSet<T>; - Είναι σαφείς οι ευθύνες των queries;
- Μπορεί η ομάδα να δοκιμάσει τη συμπεριφορά του Application χωρίς να συνδέει αναγκαστικά κάθε test με τη βάση δεδομένων;
- Δικαιολογείται το πρόσθετο κόστος συντήρησης των abstractions;
Αυτά τα ερωτήματα οδηγούν σε μια πολύ πιο χρήσιμη τεχνική συζήτηση από το αν το Repository Pattern είναι ακόμη στη μόδα.
Η ίδια αρχή ισχύει για πολλές αρχιτεκτονικές αποφάσεις. Ένα pattern δεν είναι πολύτιμο απλώς επειδή έχει συγκεκριμένο όνομα. Ούτε είναι άχρηστο επειδή ένα framework παρέχει παρόμοια λειτουργικότητα.
10. Συμπεράσματα
Το Entity Framework Core παρέχει ήδη λειτουργίες αντίστοιχες με Repository και Unit of Work. Αυτό το γεγονός πρέπει να επηρεάζει τις σχεδιαστικές μας αποφάσεις.
Δεν πρέπει, όμως, να τερματίζει τη συζήτηση.
Όταν επιλέγουμε Clean Architecture, πρέπει να κατανοούμε τα αρχιτεκτονικά όρια που θέλουμε να διατηρήσουμε. Η κατεύθυνση των εξαρτήσεων είναι θεμελιώδης για αυτόν τον σχεδιασμό και το Dependency Inversion είναι μία από τις αρχές που βοηθούν στη διατήρησή του.
Ένα custom Repository δεν είναι υποχρεωτικό σε κάθε .NET εφαρμογή. Ένα generic repository που δεν προσφέρει ουσιαστική απομόνωση μπορεί να μετατραπεί σε περιττό boilerplate.
Ένα προσεκτικά σχεδιασμένο Repository, όμως, μπορεί να παρέχει σαφές συμβόλαιο πρόσβασης στα δεδομένα, να προστατεύει το Application από εξαρτήσεις στο EF Core, να υποστηρίζει στοχευμένα tests και να διευκολύνει τη μελλοντική εξέλιξη του συστήματος.
Το ερώτημα δεν είναι αν το Repository Pattern έχει ξεπεραστεί. Το ερώτημα είναι αν η αφαίρεση εξυπηρετεί έναν σαφή αρχιτεκτονικό σκοπό στο σύστημα που κατασκευάζουμε.
Αυτή είναι η διαφορά ανάμεσα στην τυφλή εφαρμογή ενός pattern και στην τεκμηριωμένη αρχιτεκτονική απόφαση.
Πηγές
- Robert C. Martin, The Clean Architecture.
- Microsoft, Implementing the persistence layer with Entity Framework Core.
- Microsoft, Choosing a testing strategy.
- Microsoft, Testing without your production database system.
Top comments (0)