Εισαγωγή
Το CQRS είναι μία από τις αρχιτεκτονικές προσεγγίσεις που συναντάμε συχνά σε εφαρμογές .NET οι οποίες χρειάζονται σαφή διαχωρισμό ευθυνών, ελεγχόμενη επιχειρησιακή λογική και δυνατότητα εξέλιξης χωρίς να αυξάνεται ανεξέλεγκτα η πολυπλοκότητα του κώδικα.
Στην πράξη, όμως, υπάρχει μια συνηθισμένη παρανόηση: πολλοί developers συνδέουν το CQRS με το MediatR σε τέτοιο βαθμό, ώστε θεωρούν ότι η χρήση της βιβλιοθήκης αποτελεί προϋπόθεση για την εφαρμογή του pattern.
Αυτό δεν ισχύει.
Το CQRS είναι αρχιτεκτονική προσέγγιση. Το MediatR είναι μία βιβλιοθήκη που μπορεί να χρησιμοποιηθεί για τη δρομολόγηση requests προς τους κατάλληλους handlers και για την εφαρμογή κοινών συμπεριφορών κατά την επεξεργασία τους.
Μπορούμε να εφαρμόσουμε CQRS χωρίς MediatR, χωρίς εξωτερική βιβλιοθήκη και χωρίς να εγκαταστήσουμε κάποιο framework που αναλαμβάνει μεγάλο μέρος της υποδομής. Το .NET διαθέτει ήδη Dependency Injection, generics και abstractions που μας επιτρέπουν να σχεδιάσουμε τη δική μας υλοποίηση.
Το ερώτημα, επομένως, δεν είναι αν μπορούμε να εφαρμόσουμε CQRS χωρίς MediatR. Μπορούμε.
Το πραγματικό ερώτημα είναι:
Ποια είναι η ελάχιστη custom υποδομή που χρειαζόμαστε ώστε η υλοποίηση να παραμένει καθαρή, επεκτάσιμη, ελέγξιμη και συνεπής με τις αρχές της Clean Architecture;
Σε αυτό το άρθρο θα σχεδιάσουμε μια ολοκληρωμένη προσέγγιση με C#, ASP.NET Core και Entity Framework Core, ξεκινώντας από τα βασικά contracts και φτάνοντας μέχρι το Dependency Injection και τα pipeline behaviors.
1. Τι ακριβώς προσφέρει το CQRS;
Το CQRS σημαίνει Command Query Responsibility Segregation.
Η βασική ιδέα είναι ότι διαχωρίζουμε τις ενέργειες που μεταβάλλουν την κατάσταση του συστήματος από εκείνες που διαβάζουν δεδομένα.
Ένα Command εκφράζει την πρόθεση να πραγματοποιηθεί μια ενέργεια. Για παράδειγμα:
- CreateEmployeeCommand
- UpdateEmployeeCommand
- DeleteEmployeeCommand
- AssignRoleCommand
Ένα Query εκφράζει την απαίτηση ανάκτησης δεδομένων:
- GetEmployeeByIdQuery
- GetEmployeesQuery
- GetUserPermissionsQuery
Η διάκριση αυτή δεν είναι απλώς οργανωτική. Βοηθά να διαμορφώσουμε διαφορετικές συμβάσεις, διαφορετική λογική επεξεργασίας και διαφορετικές στρατηγικές πρόσβασης στα δεδομένα.
Ένα Command μπορεί να φορτώσει μια οντότητα, να ελέγξει τους επιχειρησιακούς κανόνες της και να αποθηκεύσει τις αλλαγές της. Ένα Query μπορεί να επιστρέψει απευθείας ένα DTO μέσω projection, χωρίς να χρειάζεται να φορτώσει ολόκληρο το Domain Model.
Σημαντικό: το CQRS δεν απαιτεί δύο διαφορετικές βάσεις δεδομένων. Μπορούμε να εφαρμόσουμε διαχωρισμό Commands και Queries χρησιμοποιώντας την ίδια SQL Server βάση και το ίδιο DbContext, όπου αυτό εξυπηρετεί τις ανάγκες της εφαρμογής.
Ούτε απαιτεί υποχρεωτικά Mediator, message broker, event sourcing ή microservices.
Αυτές είναι διαφορετικές σχεδιαστικές επιλογές.
2. CQRS και Mediator: δύο διαφορετικές έννοιες
Για να σχεδιάσουμε σωστά την υλοποίησή μας, πρέπει να διαχωρίσουμε τις ευθύνες τους.
Το CQRS ορίζει τον διαχωρισμό μεταξύ των ενεργειών ανάγνωσης και μεταβολής δεδομένων.
Το Mediator είναι ένα pattern επικοινωνίας. Επιτρέπει σε έναν caller να στέλνει ένα request χωρίς να χρειάζεται να γνωρίζει άμεσα τον συγκεκριμένο handler που θα το εκτελέσει.
Μια εφαρμογή μπορεί να έχει CQRS με MediatR, CQRS με custom dispatcher ή CQRS με απευθείας injection των handlers.
Και οι τρεις επιλογές είναι τεχνικά εφικτές. Δεν έχουν, όμως, τις ίδιες ιδιότητες.
Με άμεσο injection, ο caller γνωρίζει το interface του handler που χρειάζεται. Με έναν dispatcher, ο caller στέλνει ένα request και η υποδομή εντοπίζει τον κατάλληλο handler. Με έναν mediator, αποκτάμε συνήθως επιπλέον έτοιμες δυνατότητες για routing και pipeline behaviors.
Η επιλογή εξαρτάται από τις ανάγκες του συστήματος.
Για μια εφαρμογή με συγκεκριμένα, strongly typed endpoints, το άμεσο injection μπορεί να είναι επαρκές. Για ένα μεγαλύτερο σύστημα με πολλές κοινές συμπεριφορές, ίσως αξίζει να σχεδιάσουμε έναν dispatcher και ένα pipeline.
Δεν χρειάζεται να εισαγάγουμε περισσότερη έμμεση επικοινωνία από όση πραγματικά απαιτεί η εφαρμογή.
3. Η αρχιτεκτονική δομή της υλοποίησης
Ας υποθέσουμε ότι αναπτύσσουμε μια εφαρμογή διαχείρισης εργαζομένων, με SQL Server και Entity Framework Core.
Η δομή της λύσης μας θα μπορούσε να είναι η εξής:
src/
├── MyApplication.Domain/
│ ├── Entities/
│ │ └── Employee.cs
│ └── Exceptions/
│
├── MyApplication.Application/
│ ├── Abstractions/
│ │ ├── ICommand.cs
│ │ ├── IQuery.cs
│ │ ├── ICommandHandler.cs
│ │ └── IQueryHandler.cs
│ └── Employees/
│ ├── Commands/
│ │ └── CreateEmployee/
│ │ ├── CreateEmployeeCommand.cs
│ │ └── CreateEmployeeHandler.cs
│ └── Queries/
│ └── GetEmployeeById/
│ ├── GetEmployeeByIdQuery.cs
│ ├── EmployeeDto.cs
│ └── GetEmployeeByIdHandler.cs
│
├── MyApplication.Infrastructure/
│ ├── Persistence/
│ │ └── ApplicationDbContext.cs
│ └── DependencyInjection.cs
│
└── MyApplication.Api/
├── Controllers/
│ └── EmployeesController.cs
└── Program.cs
Η συγκεκριμένη δομή είναι ενδεικτική και όχι υποχρεωτική. Μπορούμε να οργανώσουμε το Application ανά feature, ώστε κάθε επιχειρησιακή λειτουργία να συγκεντρώνει τα σχετικά requests, handlers, validators και DTOs.
Η ουσία είναι τα όρια των εξαρτήσεων:
- Το Domain περιέχει τις επιχειρησιακές οντότητες και τους κανόνες τους.
- Το Application περιγράφει τις περιπτώσεις χρήσης και συντονίζει την εκτέλεσή τους.
- Το Infrastructure υλοποιεί τις τεχνικές λεπτομέρειες, όπως η αποθήκευση δεδομένων.
- Το API εκθέτει τις λειτουργίες προς τους εξωτερικούς clients.
Το CQRS δεν επιβάλλει από μόνο του αυτή τη δομή. Η Clean Architecture είναι εκείνη που μας βοηθά να ορίσουμε τα αρχιτεκτονικά όρια και τις κατευθύνσεις των εξαρτήσεων.
4. Πρώτο βήμα: Ορίζουμε τα contracts του CQRS
Το πρώτο πράγμα που χρειάζεται να αποφασίσουμε είναι ποια συμβόλαια θέλουμε να προσφέρει το Application layer.
Δεν ξεκινάμε γράφοντας dispatcher, reflection ή assembly scanning. Ξεκινάμε ορίζοντας τι σημαίνει Command, τι σημαίνει Query και τι περιμένουμε από τους αντίστοιχους handlers.
4.1 Command contracts
Στο ICommand.cs:
namespace MyApplication.Application.Abstractions;
public interface ICommand<TResult>
{
}
Το ICommand<TResult> είναι ένα marker interface. Δεν περιέχει μεθόδους. Δηλώνει ότι ένας συγκεκριμένος τύπος αντιπροσωπεύει ένα Command και προσδιορίζει τον τύπο του αποτελέσματος.
Για ενέργειες που δεν χρειάζονται επιστρεφόμενη τιμή, θα μπορούσαμε να ορίσουμε επιπλέον ένα ICommand χωρίς generic παράμετρο. Δεν είναι απαραίτητο, όμως, να υποστηρίζουμε και τις δύο περιπτώσεις από την πρώτη ημέρα.
4.2 Query contracts
Στο IQuery.cs:
namespace MyApplication.Application.Abstractions;
public interface IQuery<TResult>
{
}
Το Query διαθέτει επίσης έναν τύπο αποτελέσματος.
Για παράδειγμα, ένα Query μπορεί να επιστρέψει ένα DTO, μια συλλογή αποτελεσμάτων ή μια nullable τιμή όταν το ζητούμενο στοιχείο ενδέχεται να μην υπάρχει.
Η διαφορά από το Command δεν είναι ο τύπος επιστροφής. Είναι η σημασιολογία της λειτουργίας: ένα Query δεν πρέπει να μεταβάλλει την επιχειρησιακή κατάσταση του συστήματος.
Τα marker interfaces βοηθούν τον compiler και το Dependency Injection να αναγνωρίζουν τους τύπους που ανήκουν στην αντίστοιχη κατηγορία.
4.3 Command handler contract
Στο ICommandHandler.cs:
namespace MyApplication.Application.Abstractions;
public interface ICommandHandler<TCommand, TResult>
where TCommand : ICommand<TResult>
{
Task<TResult> HandleAsync(
TCommand command,
CancellationToken cancellationToken);
}
Αυτό το interface περιγράφει τη σύμβαση ενός Command Handler.
Το TCommand είναι ο τύπος της ενέργειας που θα εκτελεστεί και το TResult ο τύπος του αποτελέσματος.
Το CancellationToken είναι επίσης σημαντικό. Επιτρέπει στον caller να μεταφέρει το αίτημα ακύρωσης σε λειτουργίες όπως η πρόσβαση στη βάση δεδομένων.
Δεν θα πρέπει να το παραλείπουμε από την υποδομή μας απλώς επειδή υλοποιούμε custom CQRS.
4.4 Query handler contract
Στο IQueryHandler.cs:
namespace MyApplication.Application.Abstractions;
public interface IQueryHandler<TQuery, TResult>
where TQuery : IQuery<TResult>
{
Task<TResult> HandleAsync(
TQuery query,
CancellationToken cancellationToken);
}
Με αυτά τα τέσσερα contracts έχουμε ορίσει τον βασικό πυρήνα της υλοποίησης.
Δεν έχουμε ακόμη δημιουργήσει dispatcher. Δεν έχουμε γράψει reflection. Δεν έχουμε εγκαταστήσει εξωτερική βιβλιοθήκη.
Έχουμε απλώς ορίσει ένα ισχυρά τυποποιημένο συμβόλαιο για κάθε πλευρά του CQRS.
5. Υλοποίηση ενός πραγματικού Command
Ας δημιουργήσουμε τώρα μια λειτουργία καταχώρισης εργαζομένου.
Το Command θα βρίσκεται στο Application layer:
using MyApplication.Application.Abstractions;
namespace MyApplication.Application.Employees.Commands.CreateEmployee;
public sealed record CreateEmployeeCommand(
string FirstName,
string LastName,
string Email) : ICommand<Guid>;
Το Command περιγράφει την πρόθεση του client. Δεν είναι η Domain Entity και δεν χρειάζεται να κληρονομεί από αυτήν.
Αυτή η διάκριση μάς επιτρέπει να διατηρούμε το API contract ανεξάρτητο από το εσωτερικό μοντέλο της εφαρμογής.
5.1 Η Domain Entity
Στο Domain layer:
namespace MyApplication.Domain.Entities;
public sealed class Employee
{
public Guid Id { get; private set; }
public string FirstName { get; private set; } = null!;
public string LastName { get; private set; } = null!;
public string Email { get; private set; } = null!;
private Employee()
{
// Used by Entity Framework Core.
}
private Employee(
Guid id,
string firstName,
string lastName,
string email)
{
Id = id;
FirstName = firstName;
LastName = lastName;
Email = email;
}
public static Employee Create(
string firstName,
string lastName,
string email)
{
if (string.IsNullOrWhiteSpace(firstName))
throw new ArgumentException(
"First name is required.",
nameof(firstName));
if (string.IsNullOrWhiteSpace(lastName))
throw new ArgumentException(
"Last name is required.",
nameof(lastName));
if (string.IsNullOrWhiteSpace(email))
throw new ArgumentException(
"Email is required.",
nameof(email));
return new Employee(
Guid.NewGuid(),
firstName.Trim(),
lastName.Trim(),
email.Trim());
}
}
Το παράδειγμα παρουσιάζει απλούς ελέγχους εισόδου. Σε πραγματική εφαρμογή, η πολιτική επικύρωσης email και οι κανόνες μοναδικότητας πρέπει να σχεδιαστούν ξεχωριστά, ενώ η μοναδικότητα στη βάση πρέπει να προστατεύεται με κατάλληλο unique constraint.
Το σημαντικό αρχιτεκτονικό σημείο είναι ότι η δημιουργία της οντότητας πραγματοποιείται μέσα από μια ελεγχόμενη λειτουργία και όχι μέσω ανεξέλεγκτης εξωτερικής τροποποίησης των ιδιοτήτων της.
Το Domain δεν γνωρίζει το CQRS, τον controller, το DbContext ή το Dependency Injection.
5.2 Η υλοποίηση του handler
Για να αποθηκεύσουμε την οντότητα, χρειαζόμαστε έναν τρόπο πρόσβασης στα δεδομένα. Ας υποθέσουμε ότι η εφαρμογή διαθέτει ένα IApplicationDbContext στο Application layer, το οποίο υλοποιείται από το Infrastructure με EF Core.
using Microsoft.EntityFrameworkCore;
using MyApplication.Application.Abstractions;
using MyApplication.Domain.Entities;
namespace MyApplication.Application.Employees.Commands.CreateEmployee;
public sealed class CreateEmployeeHandler
: ICommandHandler<CreateEmployeeCommand, Guid>
{
private readonly IApplicationDbContext _context;
public CreateEmployeeHandler(
IApplicationDbContext context)
{
_context = context;
}
public async Task<Guid> HandleAsync(
CreateEmployeeCommand command,
CancellationToken cancellationToken)
{
var employee = Employee.Create(
command.FirstName,
command.LastName,
command.Email);
_context.Employees.Add(employee);
await _context.SaveChangesAsync(cancellationToken);
return employee.Id;
}
}
Το IApplicationDbContext είναι ένα ενδεικτικό abstraction που θα μπορούσε να οριστεί ως εξής:
using Microsoft.EntityFrameworkCore;
using MyApplication.Domain.Entities;
namespace MyApplication.Application.Abstractions;
public interface IApplicationDbContext
{
DbSet<Employee> Employees { get; }
Task<int> SaveChangesAsync(
CancellationToken cancellationToken);
}
Το Infrastructure υλοποιεί αυτό το interface με ένα πραγματικό DbContext.
Έτσι, το Application εξαρτάται από το συμβόλαιο που χρειάζεται, όχι από τη συγκεκριμένη κλάση αποθήκευσης.
Αυτό είναι εφαρμογή του Dependency Inversion Principle. Δεν χρειάζεται να εισαγάγουμε Mediator για να πετύχουμε αυτή την ανεξαρτησία.
Σημειώνεται ότι ένα τέτοιο abstraction είναι επιλογή σχεδιασμού, όχι υποχρεωτικό βήμα. Σε ορισμένες εφαρμογές μπορεί να είναι καταλληλότερα repositories ή ένα διαφορετικό persistence contract. Το ζητούμενο είναι να αποφύγουμε την άσκοπη διαρροή υποδομών στο Domain και να επιλέξουμε τα abstractions βάσει πραγματικών αναγκών.
6. Υλοποίηση ενός Query
Η πλευρά των Queries έχει διαφορετικό στόχο: την ανάκτηση δεδομένων με όσο το δυνατόν πιο κατάλληλη διαδρομή για την εκάστοτε περίπτωση χρήσης.
Δημιουργούμε το Query:
using MyApplication.Application.Abstractions;
namespace MyApplication.Application.Employees.Queries.GetEmployeeById;
public sealed record GetEmployeeByIdQuery(Guid EmployeeId)
: IQuery<EmployeeDto?>;
Και το DTO:
namespace MyApplication.Application.Employees.Queries.GetEmployeeById;
public sealed record EmployeeDto(
Guid Id,
string FirstName,
string LastName,
string Email);
Ο handler:
using Microsoft.EntityFrameworkCore;
using MyApplication.Application.Abstractions;
namespace MyApplication.Application.Employees.Queries.GetEmployeeById;
public sealed class GetEmployeeByIdHandler
: IQueryHandler<GetEmployeeByIdQuery, EmployeeDto?>
{
private readonly IApplicationDbContext _context;
public GetEmployeeByIdHandler(
IApplicationDbContext context)
{
_context = context;
}
public Task<EmployeeDto?> HandleAsync(
GetEmployeeByIdQuery query,
CancellationToken cancellationToken)
{
return _context.Employees
.AsNoTracking()
.Where(employee => employee.Id == query.EmployeeId)
.Select(employee => new EmployeeDto(
employee.Id,
employee.FirstName,
employee.LastName,
employee.Email))
.SingleOrDefaultAsync(cancellationToken);
}
}
Η χρήση του AsNoTracking() είναι κατάλληλη εδώ, επειδή το Query δεν χρειάζεται να τροποποιήσει την οντότητα.
Η projection δημιουργεί το DTO μέσα στο query, ώστε η βάση να επιστρέψει μόνο τα πεδία που χρειαζόμαστε.
Δεν υπάρχει λόγος να φορτώσουμε ολόκληρο το Domain Model, εφόσον η συγκεκριμένη περίπτωση χρήσης χρειάζεται μόνο αυτά τα δεδομένα.
Αυτό είναι ένα από τα πρακτικά πλεονεκτήματα του διαχωρισμού Commands και Queries: μπορούμε να σχεδιάζουμε την ανάγνωση και τη μεταβολή δεδομένων με διαφορετικές προτεραιότητες, ακόμη και όταν χρησιμοποιούμε την ίδια βάση.
7. Χρειαζόμαστε υποχρεωτικά Dispatcher;
Όχι.
Αυτό είναι ένα από τα σημαντικότερα σημεία της υλοποίησης.
Εφόσον το API γνωρίζει ποιο handler χρειάζεται, μπορεί να κάνει inject απευθείας το αντίστοιχο interface.
Για παράδειγμα:
[ApiController]
[Route("api/employees")]
public sealed class EmployeesController : ControllerBase
{
private readonly ICommandHandler<CreateEmployeeCommand, Guid>
_createHandler;
private readonly IQueryHandler<GetEmployeeByIdQuery, EmployeeDto?>
_getHandler;
public EmployeesController(
ICommandHandler<CreateEmployeeCommand, Guid> createHandler,
IQueryHandler<GetEmployeeByIdQuery, EmployeeDto?> getHandler)
{
_createHandler = createHandler;
_getHandler = getHandler;
}
[HttpPost]
public async Task<IActionResult> Create(
CreateEmployeeCommand command,
CancellationToken cancellationToken)
{
var id = await _createHandler.HandleAsync(
command,
cancellationToken);
return CreatedAtAction(
nameof(GetById),
new { id },
new { id });
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(
Guid id,
CancellationToken cancellationToken)
{
var result = await _getHandler.HandleAsync(
new GetEmployeeByIdQuery(id),
cancellationToken);
if (result is null)
return NotFound();
return Ok(result);
}
}
Ο controller εξαρτάται από abstractions, όχι από concrete handlers. Αυτό είναι σαφώς προτιμότερο από το να κάνει inject απευθείας το CreateEmployeeHandler, εφόσον θέλουμε να διατηρήσουμε ένα σταθερό συμβόλαιο μεταξύ Presentation και Application.
Ωστόσο, υπάρχει μια σχεδιαστική παρατήρηση: ο controller εξακολουθεί να γνωρίζει τα συγκεκριμένα request types και τα αντίστοιχα handler interfaces. Σε ένα API με λίγες λειτουργίες αυτό μπορεί να είναι απολύτως αποδεκτό.
Αν, αντίθετα, θέλουμε ένα ενιαίο σημείο εισόδου για όλα τα Commands και Queries, τότε μπορούμε να προσθέσουμε custom dispatchers.
Η απόφαση αυτή πρέπει να λαμβάνεται βάσει αναγκών, όχι επειδή το CQRS υποχρεώνει τη χρήση τους.
8. Custom Command Dispatcher και Query Dispatcher
Αν επιλέξουμε να υλοποιήσουμε έναν dispatcher, χρειαζόμαστε δύο ακόμη interfaces:
public interface ICommandDispatcher
{
Task<TResult> SendAsync<TResult>(
ICommand<TResult> command,
CancellationToken cancellationToken);
}
public interface IQueryDispatcher
{
Task<TResult> QueryAsync<TResult>(
IQuery<TResult> query,
CancellationToken cancellationToken);
}
Ο dispatcher θα αναλαμβάνει να βρει τον κατάλληλο handler και να του παραδώσει το request.
Εδώ, όμως, εμφανίζεται ένα τεχνικό ζήτημα: το IServiceProvider του .NET δεν γνωρίζει αυτόματα ποιος handler αντιστοιχεί σε ένα συγκεκριμένο request. Χρειάζεται να κατασκευάσουμε τον κατάλληλο κλειστό generic τύπο, όπως ICommandHandler<CreateEmployeeCommand, Guid>, και να τον επιλύσουμε από το DI container.
Μια πιθανή υλοποίηση είναι η εξής:
public sealed class CommandDispatcher : ICommandDispatcher
{
private readonly IServiceProvider _serviceProvider;
public CommandDispatcher(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public Task<TResult> SendAsync<TResult>(
ICommand<TResult> command,
CancellationToken cancellationToken)
{
var handlerType = typeof(ICommandHandler<,>)
.MakeGenericType(command.GetType(), typeof(TResult));
var handler = _serviceProvider.GetRequiredService(handlerType);
var method = handlerType.GetMethod(nameof(
ICommandHandler<ICommand<TResult>, TResult>.HandleAsync))!;
return (Task<TResult>)method.Invoke(
handler,
new object[] { command, cancellationToken })!;
}
}
Ο παραπάνω κώδικας δείχνει τη βασική ιδέα της δρομολόγησης, αλλά δεν θα τον θεωρούσα έτοιμη production υλοποίηση.
Η χρήση reflection εισάγει runtime αποτυχίες, δυσκολεύει την ανίχνευση σφαλμάτων κατά το compile time και απαιτεί ιδιαίτερη προσοχή στη διαχείριση των exceptions που προέρχονται από το MethodInfo.Invoke.
Σε ένα πραγματικό σύστημα θα εξέταζα μια πιο ισχυρά τυποποιημένη υλοποίηση, με προσεκτικά σχεδιασμένα adapters, ή θα επέλεγα απευθείας injection, εφόσον δεν υπήρχε πραγματική ανάγκη για dispatcher.
Αυτό είναι σημαντικό: δεν αξίζει να προσθέτουμε ένα custom abstraction εάν η υλοποίησή του δημιουργεί μεγαλύτερη πολυπλοκότητα από εκείνη που αφαιρεί.
Ο ίδιος προβληματισμός ισχύει για το Query Dispatcher. Αν δεν έχουμε ανάγκη για κοινό σημείο δρομολόγησης, δεν χρειάζεται να τον δημιουργήσουμε.
9. Dependency Injection: Πώς συνδέονται τα contracts με τους handlers;
Έχουμε ορίσει τα interfaces και τις υλοποιήσεις τους. Τώρα πρέπει να τις καταχωρίσουμε στο Dependency Injection container.
Η απλούστερη προσέγγιση είναι η χειροκίνητη καταχώριση:
builder.Services.AddScoped<
ICommandHandler<CreateEmployeeCommand, Guid>,
CreateEmployeeHandler>();
builder.Services.AddScoped<
IQueryHandler<GetEmployeeByIdQuery, EmployeeDto?>,
GetEmployeeByIdHandler>();
Αυτό είναι πλήρως έγκυρο και έχει ένα σημαντικό πλεονέκτημα: οι καταχωρίσεις είναι ρητές και εύκολα κατανοητές.
Το μειονέκτημα είναι ότι, όταν το Application layer περιλαμβάνει δεκάδες ή εκατοντάδες handlers, το Program.cs ή το αρχείο καταχωρίσεων μπορεί να γίνει δυσανάλογα μεγάλο.
Για αυτόν τον λόγο, σε μεγαλύτερα projects μπορούμε να δημιουργήσουμε μια επέκταση του IServiceCollection που συγκεντρώνει τις καταχωρίσεις.
Για παράδειγμα:
public static class DependencyInjection
{
public static IServiceCollection AddApplication(
this IServiceCollection services)
{
services.AddScoped<
ICommandHandler<CreateEmployeeCommand, Guid>,
CreateEmployeeHandler>();
services.AddScoped<
IQueryHandler<GetEmployeeByIdQuery, EmployeeDto?>,
GetEmployeeByIdHandler>();
return services;
}
}
Και στο API:
builder.Services.AddApplication();
Αυτό δεν είναι αυτόματη ανακάλυψη handlers, αλλά είναι ήδη μια καθαρότερη οργάνωση των registrations.
Αν ο αριθμός των handlers αυξηθεί σημαντικά, μπορούμε να εξετάσουμε assembly scanning με reflection ή τη βιβλιοθήκη Scrutor.
Πρέπει, όμως, να θυμόμαστε ότι το assembly scanning είναι μηχανισμός καταχώρισης στο DI container. Δεν είναι απαραίτητο συστατικό του CQRS.
Επίσης, οι handlers που χρησιμοποιούν DbContext συνήθως πρέπει να έχουν scoped lifetime, συμβατό με το lifetime του context. Δεν θα πρέπει να καταχωρίζουμε τέτοιους handlers ως singletons.
10. Το πιο σημαντικό custom κομμάτι: Cross-cutting concerns
Μέχρι αυτό το σημείο, έχουμε υλοποιήσει το βασικό CQRS.
Στην πραγματικότητα, όμως, το δυσκολότερο ζήτημα σε ένα μεγαλύτερο project δεν είναι να δημιουργήσουμε handlers. Είναι να διαχειριστούμε τις συμπεριφορές που επαναλαμβάνονται σε πολλούς handlers.
Για παράδειγμα:
- Validation
- Logging
- Authorization
- Performance measurement
- Audit logging
- Transaction management
- Idempotency, όπου απαιτείται
Αν κάθε handler πρέπει να υλοποιεί χειροκίνητα όλες αυτές τις λειτουργίες, θα δημιουργήσουμε duplication και θα δυσκολέψουμε τη συντήρηση.
Εδώ χρειαζόμαστε μια συνειδητή σχεδιαστική επιλογή.
Το MediatR προσφέρει pipeline behaviors. Χωρίς MediatR, μπορούμε να εφαρμόσουμε παρόμοιες ιδέες με decorators.
10.1 Παράδειγμα: Logging decorator
Ας υποθέσουμε ότι θέλουμε να καταγράφουμε την εκτέλεση Commands.
Μπορούμε να δημιουργήσουμε ένα decorator που υλοποιεί το ίδιο interface με τον πραγματικό handler και τον καλεί εσωτερικά:
public sealed class LoggingCommandHandler<TCommand, TResult>
: ICommandHandler<TCommand, TResult>
where TCommand : ICommand<TResult>
{
private readonly ICommandHandler<TCommand, TResult> _inner;
private readonly ILogger<LoggingCommandHandler<TCommand, TResult>>
_logger;
public LoggingCommandHandler(
ICommandHandler<TCommand, TResult> inner,
ILogger<LoggingCommandHandler<TCommand, TResult>> logger)
{
_inner = inner;
_logger = logger;
}
public async Task<TResult> HandleAsync(
TCommand command,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
_logger.LogInformation(
"Executing command {CommandType}",
typeof(TCommand).Name);
try
{
return await _inner.HandleAsync(
command,
cancellationToken);
}
finally
{
stopwatch.Stop();
_logger.LogInformation(
"Command {CommandType} completed in {ElapsedMs} ms",
typeof(TCommand).Name,
stopwatch.ElapsedMilliseconds);
}
}
}
Ο decorator δεν χρειάζεται να γνωρίζει τι σημαίνει επιχειρησιακά η δημιουργία ενός εργαζομένου. Δεν χρειάζεται να γνωρίζει το SQL schema ή τους κανόνες του Domain.
Αναλαμβάνει μόνο το logging της εκτέλεσης και στη συνέχεια μεταβιβάζει τον έλεγχο στον πραγματικό handler.
Αυτή είναι η ουσία του Decorator Pattern: επεκτείνουμε τη συμπεριφορά ενός component χωρίς να αλλάζουμε την επιχειρησιακή υλοποίησή του.
Σημειώνεται ότι το παραπάνω logging δεν καταγράφει το περιεχόμενο του Command. Αυτό είναι σκόπιμο, επειδή τα requests μπορεί να περιλαμβάνουν προσωπικά ή ευαίσθητα δεδομένα.
10.2 Πώς συνδέεται ο decorator με το DI;
Η ύπαρξη του decorator δεν σημαίνει ότι το DI container θα τον εφαρμόσει αυτόματα.
Χρειάζεται να οργανώσουμε τις καταχωρίσεις έτσι ώστε το interface να επιλύεται μέσω του decorator και όχι απευθείας μέσω του εσωτερικού handler.
Για λίγους handlers, μπορούμε να κάνουμε χειροκίνητη σύνθεση. Για παράδειγμα, θα μπορούσαμε να χρησιμοποιήσουμε ένα factory registration που δημιουργεί πρώτα τον πραγματικό handler και στη συνέχεια τον τυλίγει με τον logging decorator.
Σε μεγαλύτερο σύστημα, μια βιβλιοθήκη όπως το Scrutor μπορεί να διευκολύνει την εφαρμογή decorators σε καταχωρισμένες υπηρεσίες.
Πρέπει να προσέχουμε τη σειρά των decorators, καθώς και να διασφαλίζουμε ότι το factory δεν επιλύει ξανά το ίδιο interface με αποτέλεσμα κυκλική εξάρτηση.
Δεν αρκεί να γράψουμε μια κλάση LoggingCommandHandler. Πρέπει να διασφαλίσουμε ότι η πραγματική διαδρομή εκτέλεσης περνάει μέσα από αυτήν.
11. Validation χωρίς Mediator
Το validation είναι ένα άλλο χαρακτηριστικό που συχνά συνδέεται με το MediatR.
Δεν απαιτεί, όμως, Mediator.
Μπορούμε να χρησιμοποιήσουμε FluentValidation ή έναν δικό μας μηχανισμό επικύρωσης και να τον ενσωματώσουμε στη ροή εκτέλεσης.
Υπάρχουν δύο βασικές προσεγγίσεις.
Η πρώτη είναι να καλεί κάθε handler τον validator που χρειάζεται. Είναι απλή, αλλά μπορεί να δημιουργήσει επαναλαμβανόμενο κώδικα.
Η δεύτερη είναι να χρησιμοποιήσουμε έναν decorator ή μια κοινή pipeline abstraction που εκτελεί τους validators πριν καλέσει τον πραγματικό handler.
Η δεύτερη προσέγγιση είναι συνήθως πιο κατάλληλη όταν θέλουμε συνεπή συμπεριφορά σε μεγάλο αριθμό Commands.
Πρέπει, ωστόσο, να ξεχωρίζουμε το validation των εισερχόμενων δεδομένων από τους επιχειρησιακούς κανόνες.
Ένας validator μπορεί να ελέγξει αν ένα email έχει επιτρεπόμενη μορφή ή αν ένα απαιτούμενο πεδίο είναι κενό. Δεν πρέπει, όμως, να μεταφέρουμε κάθε επιχειρησιακό κανόνα σε ένα εξωτερικό validation layer.
Για παράδειγμα, η απαγόρευση μιας μετάβασης κατάστασης σε έναν εργαζόμενο μπορεί να αποτελεί κανόνα του Domain και να πρέπει να προστατεύεται από την ίδια την οντότητα, ανεξάρτητα από το ποιος καλεί τη λειτουργία.
Η αρχιτεκτονική μας πρέπει να διασφαλίζει ότι οι κανόνες δεν παρακάμπτονται όταν αλλάζει το API, προστίθεται background worker ή εισάγεται ένα νέο σημείο εισόδου.
12. Authorization: Πού πρέπει να εφαρμόζεται;
Το authorization χρειάζεται ακόμη μεγαλύτερη προσοχή, επειδή αφορά την ασφάλεια του συστήματος.
Μπορούμε να εφαρμόσουμε authorization στο ASP.NET Core API, μέσω policies και attributes, αλλά και σε επίπεδο Application όταν οι έλεγχοι εξαρτώνται από την επιχειρησιακή λειτουργία.
Για παράδειγμα, το API μπορεί να απαιτεί authenticated χρήστη, ενώ το Application layer μπορεί να πρέπει να ελέγξει αν ο συγκεκριμένος χρήστης έχει δικαίωμα να τροποποιήσει έναν συγκεκριμένο εργαζόμενο.
Ένας custom authorization decorator θα μπορούσε να εκτελεί έναν κοινό έλεγχο πριν από τον handler. Αυτό, όμως, απαιτεί προσεκτικό σχεδιασμό των contracts και της πληροφορίας που μεταφέρεται από το authenticated context.
Δεν θα έβαζα αυθαίρετα ολόκληρη τη λογική authorization μέσα σε έναν γενικό dispatcher. Ο dispatcher πρέπει να παραμένει υπεύθυνος για τη δρομολόγηση και όχι να μετατραπεί σε κεντρική κλάση που γνωρίζει κάθε επιχειρησιακό κανόνα.
Επίσης, το Domain δεν πρέπει να εξαρτάται από το ASP.NET Core HttpContext. Αν ένας επιχειρησιακός κανόνας χρειάζεται πληροφορία για τον τρέχοντα χρήστη, αυτή πρέπει να μεταφέρεται μέσα από ένα κατάλληλο abstraction, όπου αυτό είναι αναγκαίο.
Η επιλογή του μηχανισμού authorization εξαρτάται από το είδος του ελέγχου. Δεν είναι κάθε authorization rule κατάλληλο για ένα γενικό pipeline.
13. Transactions: Δεν τις αναλαμβάνει αυτόματα το CQRS
Ένα από τα πιο συνηθισμένα λάθη είναι να θεωρούμε ότι, επειδή έχουμε έναν handler ανά Command, έχουμε αυτόματα και σωστή διαχείριση transactions.
Δεν ισχύει.
Το CQRS δεν ορίζει πώς θα διαχειριστούμε τις συναλλαγές στη βάση δεδομένων.
Στο απλό παράδειγμά μας, το SaveChangesAsync() του EF Core αποθηκεύει τις αλλαγές και, για τις συνηθισμένες σχετικές λειτουργίες που εκτελούνται σε μία κλήση, το EF Core παρέχει transaction behavior όταν υποστηρίζεται από τον provider.
Αν όμως μια περίπτωση χρήσης περιλαμβάνει πολλές ξεχωριστές ενέργειες αποθήκευσης, πολλαπλά resources ή εξωτερικά συστήματα, πρέπει να σχεδιάσουμε συνειδητά τα όρια της συναλλαγής.
Δεν θα πρόσθετα transaction decorator μόνο και μόνο επειδή υπάρχει CQRS.
Θα τον πρόσθετα όταν το σύστημα έχει συγκεκριμένες ανάγκες διαχείρισης συναλλαγών που δεν καλύπτονται επαρκώς από το υπάρχον persistence layer.
Αν μια λειτουργία ενημερώνει τη βάση και στη συνέχεια στέλνει μήνυμα σε εξωτερικό broker, πρέπει επίσης να σκεφτούμε τι θα συμβεί αν η μία ενέργεια επιτύχει και η άλλη αποτύχει. Σε τέτοιες περιπτώσεις, τεχνικές όπως το transactional outbox μπορεί να είναι καταλληλότερες από την προσπάθεια να δημιουργήσουμε μια γενική transaction abstraction για κάθε πιθανή περίπτωση.
14. Πότε χρειάζεται custom dispatcher και πότε όχι;
Ας συνοψίσουμε τη σχεδιαστική απόφαση.
Επιλογή Α: Άμεσο injection των handlers
Ο controller εξαρτάται από το αντίστοιχο handler interface.
Πλεονεκτήματα:
- Λιγότερος κώδικας υποδομής.
- Strong typing σε compile time.
- Εύκολη μετάβαση στον πραγματικό handler μέσω του debugger.
- Δεν απαιτείται runtime routing.
Μειονεκτήματα:
- Ο caller γνωρίζει τον τύπο του handler που χρειάζεται.
- Δεν υπάρχει αυτόματα ενιαίο σημείο δρομολόγησης για όλα τα requests.
- Η εφαρμογή κοινών behaviors χρειάζεται ξεχωριστό σχεδιασμό.
Επιλογή Β: Custom dispatcher
Ο caller στέλνει το Command ή το Query σε ένα κοινό abstraction.
Πλεονεκτήματα:
- Ενιαίο σημείο εισόδου για την εκτέλεση requests.
- Δυνατότητα κεντρικής σύνθεσης της ροής εκτέλεσης.
- Μπορεί να είναι χρήσιμο σε generic infrastructure, background processing ή συστήματα που χρειάζονται runtime routing.
Μειονεκτήματα:
- Περισσότερος κώδικας υποδομής.
- Αν χρησιμοποιηθεί reflection, αυξάνεται η πολυπλοκότητα.
- Χρειάζεται προσεκτικός σχεδιασμός του error handling, των registrations και των pipeline behaviors.
Για ένα τυπικό ASP.NET Core API, δεν θα θεωρούσα τον custom dispatcher υποχρεωτικό. Θα αξιολογούσα πρώτα αν η αφαίρεση που προσφέρει δικαιολογεί το κόστος της.
Αν η εφαρμογή χρειάζεται dispatcher, θα φρόντιζα να είναι όσο το δυνατόν πιο απλός, χωρίς επιχειρησιακή λογική και χωρίς να μετατρέπεται σε service locator για ολόκληρη την εφαρμογή.
15. Unit testing: Ένα από τα πραγματικά πλεονεκτήματα
Μια σωστά σχεδιασμένη υλοποίηση CQRS χωρίς Mediator μπορεί να είναι ιδιαίτερα εύκολη στη δοκιμή.
Ο handler είναι μια απλή κλάση με σαφείς εξαρτήσεις και μία συγκεκριμένη ευθύνη.
Για παράδειγμα, μπορούμε να γράψουμε ένα unit test για το CreateEmployeeHandler, ελέγχοντας ότι μια έγκυρη ενέργεια δημιουργεί και αποθηκεύει τον εργαζόμενο.
Το test μπορεί να χρησιμοποιήσει mock ή fake του persistence abstraction, ανάλογα με τη στρατηγική δοκιμών.
Παράλληλα, χρειάζονται integration tests για να επαληθεύσουμε ότι το EF Core, οι mappings, τα constraints και η πραγματική βάση λειτουργούν σωστά.
Δεν θα προσπαθούσα να αποδείξω τη σωστή λειτουργία της βάσης χρησιμοποιώντας αποκλειστικά mocks.
Επίσης, αν έχουμε custom dispatcher, χρειάζονται tests που να επιβεβαιώνουν ότι κάθε request επιλύεται στον σωστό handler και ότι τα pipeline behaviors εκτελούνται με τη σωστή σειρά.
Η απουσία MediatR δεν μειώνει από μόνη της την testability. Αυτή εξαρτάται κυρίως από τον σχεδιασμό των εξαρτήσεων και των συμβολαίων.
16. Συνηθισμένα αρχιτεκτονικά λάθη
Λάθος 1: Θεωρούμε ότι CQRS σημαίνει δύο βάσεις δεδομένων
Ο διαχωρισμός Commands και Queries μπορεί να υλοποιηθεί με μία κοινή βάση. Διαφορετικά read models ή ξεχωριστές βάσεις αποτελούν πρόσθετες επιλογές.
Λάθος 2: Τοποθετούμε όλη την επιχειρησιακή λογική στους handlers
Ο handler πρέπει κυρίως να συντονίζει τη συγκεκριμένη περίπτωση χρήσης. Οι ουσιαστικοί επιχειρησιακοί κανόνες πρέπει να βρίσκονται στο κατάλληλο Domain Model ή σε κατάλληλες domain services.
Λάθος 3: Δημιουργούμε dispatcher χωρίς πραγματική ανάγκη
Αν ο caller γνωρίζει ήδη τον handler που χρειάζεται, το άμεσο injection μπορεί να είναι απλούστερο και επαρκές.
Λάθος 4: Γράφουμε custom Mediator και τελικά ανακατασκευάζουμε ολόκληρο framework
Ένας dispatcher, ένας μηχανισμός reflection, ένα σύστημα registration, decorators, validation pipelines, event publishing και transaction management μπορούν να εξελιχθούν σε σημαντική υποδομή.
Αυτό δεν είναι απαραίτητα λάθος, αλλά πρέπει να δικαιολογείται από τις απαιτήσεις του συστήματος. Δεν χρειάζεται να υλοποιούμε κάθε πιθανή δυνατότητα πριν υπάρξει πραγματική ανάγκη.
Λάθος 5: Θεωρούμε ότι η απουσία MediatR εξασφαλίζει καλύτερη αρχιτεκτονική
Η ποιότητα της αρχιτεκτονικής δεν καθορίζεται από το αν χρησιμοποιούμε εξωτερική βιβλιοθήκη.
Καθορίζεται από τα όρια των εξαρτήσεων, τη σαφήνεια των contracts, τη διαχείριση των επιχειρησιακών κανόνων και τη δυνατότητα εξέλιξης του συστήματος.
17. Τι θα επέλεγα σε ένα πραγματικό production project;
Για μια εφαρμογή που ακολουθεί Clean Architecture και έχει σαφή διαχωρισμό Commands και Queries, θα ξεκινούσα από μια μικρή, ρητή υλοποίηση.
Θα δημιουργούσα τα contracts ICommand<TResult>, IQuery<TResult>, ICommandHandler<TCommand, TResult> και IQueryHandler<TQuery, TResult>.
Θα διατηρούσα κάθε handler υπεύθυνο για μία συγκεκριμένη περίπτωση χρήσης και θα χρησιμοποιούσα Dependency Injection για να συνδέσω τις υλοποιήσεις με τα interfaces τους.
Θα επέλεγα απευθείας injection όταν αυτό επαρκεί. Θα πρόσθετα custom dispatcher μόνο εφόσον υπήρχε σαφής ανάγκη για ενιαίο routing.
Για τα cross-cutting concerns θα εξέταζα decorators, validation abstractions και κατάλληλες υπηρεσίες του ASP.NET Core. Δεν θα συγκέντρωνα όλες τις ευθύνες σε έναν κεντρικό dispatcher.
Τέλος, θα διασφάλιζα ότι οι handlers παραμένουν testable, ότι τα transactions έχουν σαφή όρια και ότι το Domain δεν εξαρτάται από τεχνικές λεπτομέρειες του Infrastructure.
Η προσέγγιση αυτή δεν είναι η μοναδική σωστή λύση. Είναι, όμως, μια συνειδητή αρχιτεκτονική επιλογή που μπορεί να προσφέρει έλεγχο και σαφήνεια χωρίς να εισάγει εξαρτήσεις ή πολυπλοκότητα χωρίς συγκεκριμένο όφελος.
Συμπέρασμα
Το CQRS δεν είναι συνώνυμο του MediatR. Είναι μια προσέγγιση που διαχωρίζει τις λειτουργίες ανάγνωσης από τις λειτουργίες μεταβολής της κατάστασης του συστήματος.
Μπορούμε να το υλοποιήσουμε με custom interfaces, handlers και το ενσωματωμένο Dependency Injection του .NET. Μπορούμε επίσης να προσθέσουμε dispatcher και pipeline behaviors όταν οι απαιτήσεις της εφαρμογής το δικαιολογούν.
Το κρίσιμο σημείο είναι να γνωρίζουμε τι προσφέρει κάθε abstraction και ποιο πρόβλημα λύνει.
Δεν χρειάζεται να δημιουργήσουμε έναν custom Mediator μόνο και μόνο για να πούμε ότι έχουμε CQRS. Χρειάζεται να σχεδιάσουμε σωστά τις εξαρτήσεις, να διαχωρίσουμε τις ευθύνες και να διασφαλίσουμε ότι το σύστημα παραμένει εύκολο στη συντήρηση και στην επέκταση.
Η αρχιτεκτονική ποιότητα δεν προκύπτει από τον αριθμό των abstractions ή των βιβλιοθηκών που χρησιμοποιούμε. Προκύπτει από το αν οι σχεδιαστικές επιλογές μας λύνουν πραγματικά προβλήματα, διατηρώντας τον κώδικα κατανοητό και ελεγχόμενο.
Πηγές και περαιτέρω μελέτη
- Microsoft Learn — Implementing the microservice application layer using the Web API
Εξηγεί την υλοποίηση Commands και Handlers, τη χρήση του Dependency Injection και τον ρόλο του Mediator και των pipeline behaviors.
- Microsoft Learn — Designing a DDD-oriented microservice
Αναλύει τον διαχωρισμό Domain, Application και Infrastructure και τον ρόλο της επιχειρησιακής λογικής στο Domain Model.
- Adrián Bailador — CQRS Without MediatR: Hand-Rolled Command and Query Handlers in .NET
Παρουσιάζει μια εναλλακτική προσέγγιση με custom contracts, dispatchers και pipeline behaviors.
Παράδειγμα υλοποίησης σε .NET με custom handlers, Dependency Injection και decorators.
Top comments (0)