Description
Τι είναι το Decorator pattern, ποια προβλήματα λύνει και πώς το χρησιμοποιούμε για authorization και telemetry σε CQRS handlers, χωρίς MediatR.
Το Decorator pattern σου επιτρέπει να προσθέσεις συμπεριφορά (logging, authorization, telemetry, caching, retry…) σε ένα αντικείμενο χωρίς να αλλάξεις τον κώδικά του και χωρίς κληρονομικότητα. «Τυλίγεις» το αντικείμενο με ένα άλλο που υλοποιεί το ίδιο interface. Σε μια Clean Architecture με CQRS (χωρίς MediatR) είναι ο πιο καθαρός τρόπος να έχεις ένα «pipeline» γύρω από κάθε command και query.
Το άρθρο έχει δύο μέρη:
Κατανόηση: τι είναι, τι πρόβλημα λύνει, πότε (και πότε όχι) να το χρησιμοποιήσεις.
Εφαρμογή: πραγματικά παραδείγματα σε .NET: authorization και telemetry decorators γύρω από CQRS handlers, registration στο DI χωρίς βιβλιοθήκες, tests και παγίδες.
Μέρος 1 : Κατανόηση του Decorator
Ας πούμε ότι έχεις ένα use case: «φέρε μια παραγγελία με βάση το id». Η ουσία του είναι τρεις γραμμές:
public async Task<OrderDto> HandleAsync(GetOrderByIdQuery query, CancellationToken ct)
{
var order = await _orders.GetByIdAsync(query.OrderId, ct)
?? throw new NotFoundException(nameof(Order), query.OrderId);
return OrderDto.From(order);
}
Μετά έρχονται οι απαιτήσεις:
«Μόνο όποιος έχει δικαίωμα orders.read να μπορεί να το καλέσει.»
«Θέλουμε να ξέρουμε πόσο χρόνο παίρνει κάθε query και πόσα αποτυγχάνουν.»
«Γράψε log όταν κάτι σκάει.»
«Αυτό το query να γίνεται cache για 30 δευτερόλεπτα.»
Καμία από αυτές δεν αφορά το «πώς φέρνω μια παραγγελία». Είναι cross-cutting concerns: θέματα που «κόβουν οριζόντια» όλη την εφαρμογή και θα τα χρειαστείς σε δεκάδες ή εκατοντάδες use cases.
Το ερώτημα είναι: πού γράφεις αυτόν τον κώδικα;
Λύση #1 που δεν δουλεύει: «το βάζω μέσα στην κλάση»
Η πρώτη, φυσική αντίδραση:
public async Task HandleAsync(GetOrderByIdQuery query, CancellationToken ct)
{
// authorization
if (!_currentUser.IsAuthenticated)
throw new UnauthorizedException();
if (!_currentUser.HasPermission("orders.read"))
throw new ForbiddenException();
// telemetry
using var activity = _activitySource.StartActivity("GetOrderById");
var started = Stopwatch.GetTimestamp();
try
{
// η πραγματική δουλειά — 3 γραμμές από τις 25
var order = await _orders.GetByIdAsync(query.OrderId, ct)
?? throw new NotFoundException(nameof(Order), query.OrderId);
return OrderDto.From(order);
}
catch (Exception ex)
{
_logger.LogError(ex, "GetOrderById failed");
activity?.SetStatus(ActivityStatusCode.Error);
throw;
}
finally
{
_duration.Record(Stopwatch.GetElapsedTime(started).TotalMilliseconds);
}
}
Τι πήγε στραβά:
Πρόβλημα Τι σημαίνει στην πράξη
Θόρυβος Η ουσία (3 γραμμές) χάνεται μέσα σε 20+ γραμμές «υποδομής».
Copy-paste Το ίδιο μπλοκ θα γραφτεί σε κάθε handler. 100 handlers = 100 αντίγραφα.
Ασυνέπεια Κάποιος θα το ξεχάσει ή θα το γράψει λίγο διαφορετικά. Ένα ξεχασμένο authorization check είναι security bug.
Πολλές ευθύνες Η κλάση αλλάζει αν αλλάξει η λογική, το telemetry ή το authorization (παραβίαση SRP).
Δύσκολα tests Για να τεστάρεις «φέρνει σωστά την παραγγελία» πρέπει να στήσεις current user, activity source, logger…
Λύση #2 που δεν δουλεύει: κληρονομικότητα
«Ας φτιάξω μια base class και να κάνω override». Με ένα concern φαίνεται εντάξει:
public class LoggingEmailSender : EmailSender
{
public override async Task SendAsync(Message message)
{
_logger.LogInformation("Sending...");
await base.SendAsync(message);
}
}
Τώρα θέλεις και retry. Και κάποιες φορές μόνο retry χωρίς logging. Και έχεις και SmsSender:
EmailSender
├── LoggingEmailSender
├── RetryingEmailSender
└── LoggingRetryingEmailSender
SmsSender
├── LoggingSmsSender
├── RetryingSmsSender
└── LoggingRetryingSmsSender
Αυτό λέγεται class explosion. Με N υλοποιήσεις και M προαιρετικά concerns χρειάζεσαι μέχρι N × 2^M κλάσεις. Επιπλέον:
η κληρονομικότητα αποφασίζεται στο compile time — δεν μπορείς να «προσθέσεις retry» από configuration,
η C# έχει μία base class — δεν συνδυάζεις LoggingEmailSender και RetryingEmailSender,
η παιδική κλάση εξαρτάται από τις εσωτερικές λεπτομέρειες της base (fragile base class).
Χρειαζόμαστε κάτι που να συνδυάζεται ελεύθερα, την ώρα που στήνουμε την εφαρμογή.
Η ιδέα του Decorator με μια αναλογία
Σκέψου ένα κινητό και μια θήκη.
Η θήκη δεν αλλάζει το κινητό. Δεν ανοίγεις το κινητό να βάλεις μέσα προστασία.
Με τη θήκη, το κινητό χρησιμοποιείται ακριβώς το ίδιο: πατάς τα ίδια κουμπιά, ακουμπάς την ίδια οθόνη. Η «διεπαφή» είναι ίδια.
Η θήκη προσθέτει κάτι (προστασία από πτώσεις).
Μπορείς να βάλεις και screen protector. Και τα δύο μαζί. Ή κανένα.
Μπορείς να τα βγάλεις όποτε θέλεις, χωρίς να πειράξεις το κινητό.
Αυτό είναι το Decorator: ένα αντικείμενο που «ντύνει» ένα άλλο, μοιάζει ακριβώς σαν αυτό απ' έξω, και προσθέτει συμπεριφορά πριν ή μετά από τη δουλειά του.
Ο επίσημος ορισμός (Gang of Four), σε ελεύθερη απόδοση:
Προσθέτει δυναμικά επιπλέον ευθύνες σε ένα αντικείμενο. Είναι μια ευέλικτη εναλλακτική της κληρονομικότητας για επέκταση λειτουργικότητας.
Η δομή του pattern
Τέσσερις ρόλοι:
┌──────────────────────┐
│ «interface» │
│ IComponent │◄──────────────┐
│ + Operation() │ │
└──────────▲───────────┘ │
│ υλοποιεί │ κρατάει
┌─────────────┴─────────────┐ │ αναφορά
│ │ │
┌────────┴─────────┐ ┌─────────┴──────────┐ │
│ ConcreteComponent│ │ Decorator │──┘
│ (η πραγματική │ │ - inner: IComponent│
│ δουλειά) │ │ + Operation() │
└──────────────────┘ └─────────▲──────────┘
│
┌─────────────┴─────────────┐
│ │
┌───────┴────────┐ ┌───────┴────────┐
│ LoggingDecorator│ │ RetryDecorator │
└────────────────┘ └────────────────┘
Ρόλος Τι κάνει Στο παράδειγμά μας
Component Το συμβόλαιο (interface) που όλοι τηρούν IEmailSender
Concrete Component Η πραγματική δουλειά SmtpEmailSender
Decorator Υλοποιεί το ίδιο interface και κρατάει ένα inner του ίδιου τύπου LoggingEmailSender, RetryingEmailSender
Client Μιλάει μόνο με το interface — δεν ξέρει αν έχει decorator ή όχι Ο controller / το service που στέλνει email
Το «μαγικό» κομμάτι είναι ότι ο decorator είναι IComponent και ταυτόχρονα έχει ένα IComponent. Γι' αυτό μπορείς να βάλεις decorator μέσα σε decorator μέσα σε decorator — σαν ρώσικες κούκλες (matryoshka).
Ένα πρώτο παράδειγμα σε C#
Ξεκινάμε χωρίς DI, χωρίς frameworks — καθαρό pattern.
- Το συμβόλαιο:
public interface IEmailSender
{
Task SendAsync(EmailMessage message, CancellationToken ct = default);
}
- Η πραγματική υλοποίηση — κάνει μόνο τη δουλειά της:
public sealed class SmtpEmailSender(SmtpClient smtp) : IEmailSender
{
public Task SendAsync(EmailMessage message, CancellationToken ct = default)
=> smtp.SendMailAsync(message.ToMailMessage(), ct);
}
- Ένας decorator για logging:
public sealed class LoggingEmailSender(
IEmailSender inner, // ← το αντικείμενο που «ντύνουμε»
ILogger logger) : IEmailSender // ← το ίδιο interface
{
public async Task SendAsync(EmailMessage message, CancellationToken ct = default)
{
logger.LogInformation("Sending email to {To}", message.To); // πριν
await inner.SendAsync(message, ct); // η πραγματική δουλειά
logger.LogInformation("Email to {To} sent", message.To); // μετά
}
}
- Ένας decorator για retry:
public sealed class RetryingEmailSender(IEmailSender inner, int maxAttempts) : IEmailSender
{
public async Task SendAsync(EmailMessage message, CancellationToken ct = default)
{
for (var attempt = 1; ; attempt++)
{
try
{
await inner.SendAsync(message, ct);
return;
}
catch (SmtpException) when (attempt < maxAttempts)
{
await Task.Delay(TimeSpan.FromSeconds(attempt), ct);
}
}
}
}
- Σύνθεση — εδώ βλέπεις τη δύναμη του pattern:
IEmailSender sender =
new LoggingEmailSender(
new RetryingEmailSender(
new SmtpEmailSender(smtp),
maxAttempts: 3),
logger);
await sender.SendAsync(message);
Τι συμβαίνει όταν καλείς SendAsync:
LoggingEmailSender → log "Sending..."
RetryingEmailSender → προσπάθεια 1, 2, 3...
SmtpEmailSender → στέλνει το email
RetryingEmailSender ← επιστρέφει
LoggingEmailSender ← log "sent"
Παρατήρησε:
Το SmtpEmailSender δεν άλλαξε ποτέ.
Ο client βλέπει ένα IEmailSender. Δεν ξέρει ότι από πίσω υπάρχουν τρία αντικείμενα.
Θέλεις retry χωρίς logging; Αφαιρείς ένα new.
Αν email και SMS υλοποιούν ένα κοινό IMessageSender, οι ίδιοι decorators δουλεύουν και για τα δύο. Από N × 2^M κλάσεις πέφτουμε σε N + M.
💡 Η σειρά μετράει. Logging(Retry(Smtp)) γράφει ένα log για όλη την αποστολή. Retry(Logging(Smtp)) γράφει log σε κάθε προσπάθεια. Και τα δύο είναι σωστά — απλώς κάνουν διαφορετικά πράγματα. Θα το ξαναδούμε στο Μέρος 2.
Οι 5 κανόνες ενός σωστού decorator
Υλοποιεί το ίδιο interface με αυτό που διακοσμεί. Αλλιώς δεν είναι decorator (μάλλον είναι adapter).
Παίρνει το inner μέσω constructor — ως interface, όχι ως concrete class. Έτσι μπορεί να τυλίξει οτιδήποτε, ακόμη και άλλον decorator.
Καλεί το inner (εκτός αν σκόπιμα κάνει short-circuit, π.χ. authorization που απορρίπτει ή cache hit).
Δεν αλλάζει το συμβόλαιο. Ίδιες παράμετροι, ίδιος τύπος επιστροφής, ίδια σημασία. Δεν «μασάει» exceptions που ο client περιμένει, δεν αλλάζει το αποτέλεσμα κρυφά. (Αυτό είναι το Liskov Substitution Principle.)
Κάνει ένα πράγμα. Ένας decorator για logging, ένας για retry. Όχι «ο decorator που τα κάνει όλα».
Decorator και SOLID
Το Decorator είναι από τα patterns που «δένουν» πιο φυσικά με το SOLID:
Αρχή Πώς τη βοηθάει ο Decorator
Single Responsibility Ο handler κάνει μόνο business logic. Κάθε decorator κάνει ένα cross-cutting concern.
Open/Closed Προσθέτεις συμπεριφορά (νέος decorator) χωρίς να αλλάξεις τον υπάρχοντα κώδικα.
Liskov Substitution Ο decorator μπαίνει όπου μπαίνει και το πραγματικό αντικείμενο. Ο client δεν καταλαβαίνει διαφορά.
Interface Segregation Μικρά interfaces (π.χ. ένα HandleAsync) κάνουν τους decorators εύκολους. Μεγάλα interfaces με 15 μεθόδους κάνουν τους decorators βάσανο.
Dependency Inversion Και ο client και ο decorator εξαρτώνται από abstraction, όχι από υλοποίηση.
Decorator vs Proxy vs Adapter vs Middleware
Μοιάζουν, αλλά έχουν διαφορετικό σκοπό:
Pattern Ίδιο interface; Σκοπός Παράδειγμα
Decorator ✅ Ναι Προσθέτει συμπεριφορά logging, telemetry, retry
Proxy ✅ Ναι Ελέγχει την πρόσβαση στο αντικείμενο lazy loading, remote proxy, EF Core lazy-loading proxies
Adapter ❌ Όχι Μετατρέπει ένα interface σε άλλο wrapper γύρω από SDK τρίτου
Chain of Responsibility ✅ Συνήθως Κάθε κρίκος αποφασίζει αν θα χειριστεί ή θα το περάσει παρακάτω event handlers, validation chain
ASP.NET Middleware — Ίδια ιδέα με decorator, αλλά σε επίπεδο HTTP request authentication, CORS, exception handling
MediatR Pipeline Behavior ✅ Decorator γύρω από handlers, έτοιμος από βιβλιοθήκη validation, logging behaviors
Στην πράξη η γραμμή Decorator/Proxy είναι λεπτή (ένας authorization decorator μοιάζει πολύ με protection proxy). Μην κολλάς στο όνομα — κοίτα την πρόθεση.
💡 Γιατί όχι απλώς middleware; Το ASP.NET middleware τρέχει μόνο όταν έρχεται HTTP request. Ένας decorator γύρω από τον handler τρέχει όπου κι αν καλείται το use case: από controller, από background job, από message consumer, από test. Το authorization ενός use case ανήκει στο use case, όχι στο transport.
Πότε να το χρησιμοποιήσεις και πότε όχι
✅ Χρησιμοποίησέ το όταν:
έχεις cross-cutting concerns που επαναλαμβάνονται σε πολλές υλοποιήσεις του ίδιου interface (logging, telemetry, authorization, validation, caching, retry, transactions, auditing),
θέλεις να ενεργοποιείς/απενεργοποιείς συμπεριφορά από configuration χωρίς αλλαγή κώδικα,
θέλεις να επεκτείνεις κλάση που δεν μπορείς ή δεν πρέπει να αλλάξεις (π.χ. από βιβλιοθήκη),
τα interfaces σου είναι μικρά (μία ή δύο μέθοδοι).
❌ Μην το χρησιμοποιήσεις όταν:
η συμπεριφορά αφορά μία μόνο κλάση — γράψ' την μέσα και τελείωσες,
θέλεις να αλλάξεις το interface — αυτό είναι adapter,
η «επιπλέον συμπεριφορά» είναι στην πραγματικότητα business logic (π.χ. «αν η παραγγελία είναι πάνω από 1000€ στείλε email στον manager»). Business κανόνες που κρύβονται σε decorators γίνονται αόρατοι,
το interface έχει πολλές μεθόδους και θα έπρεπε να γράψεις 15 μεθόδους που απλώς κάνουν inner.X(),
η σειρά και ο αριθμός των decorators γίνεται τόσο μεγάλος που κανείς δεν ξέρει τι τρέχει πριν από τι.
Μέρος 2 — Decorators σε Clean Architecture με CQRS
Τώρα που ξέρουμε το pattern, ας δούμε πού λάμπει πραγματικά: γύρω από command και query handlers.
Το setup: CQRS χωρίς MediatR
Πολλές ομάδες χρησιμοποιούν MediatR, που δίνει έτοιμα «pipeline behaviors». Αλλά δεν τον χρειάζεσαι για CQRS. Μερικά μικρά interfaces αρκούν — και, όπως θα δούμε, τα pipeline behaviors είναι απλώς decorators.
namespace MyApp.Application.Common.Cqrs;
// Κοινή βάση για commands και queries.
public interface IRequest;
// Command: αλλάζει state.
public interface ICommand : IRequest;
// Query: μόνο διαβάζει.
public interface IQuery : IRequest;
public interface IRequestHandler
where TRequest : IRequest
{
Task HandleAsync(TRequest request, CancellationToken cancellationToken = default);
}
public interface ICommandHandler : IRequestHandler
where TCommand : ICommand;
public interface IQueryHandler : IRequestHandler
where TQuery : IQuery;
Ένα query και ο handler του — μόνο business logic:
namespace MyApp.Application.Orders.GetOrderById;
[AuthorizeByPermissions(Permissions.Orders.Read)]
public sealed record GetOrderByIdQuery(long OrderId) : IQuery;
internal sealed class GetOrderByIdQueryHandler(IOrderRepository orders)
: IQueryHandler
{
public async Task HandleAsync(GetOrderByIdQuery query, CancellationToken cancellationToken = default)
{
var order = await orders.GetByIdAsync(query.OrderId, cancellationToken)
?? throw new NotFoundException(nameof(Order), query.OrderId);
return OrderDto.From(order);
}
}
Ο controller κάνει inject το interface και δεν ξέρει τίποτα για decorators:
[ApiController]
[Route("api/orders")]
public sealed class OrdersController(
IQueryHandler getOrderById,
ICommandHandler createOrder) : ControllerBase
{
[HttpGet("{id:long}")]
public async Task> GetById(long id, CancellationToken cancellationToken)
=> Ok(await getOrderById.HandleAsync(new GetOrderByIdQuery(id), cancellationToken));
[HttpPost]
public async Task<ActionResult<long>> Create(CreateOrderCommand command, CancellationToken cancellationToken)
{
var id = await createOrder.HandleAsync(command, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id }, id);
}
}
Αυτό είναι το κλειδί: επειδή όλοι οι handlers μοιράζονται δύο generic interfaces, γράφουμε έναν decorator ανά concern και καλύπτει όλους τους handlers.
Πού ζει κάθε κομμάτι
Στην Clean Architecture οι εξαρτήσεις δείχνουν προς τα μέσα. Κάθε decorator μπαίνει στο layer που «του ανήκει» το concern:
src/
├── MyApp.Domain/ ← entities, business rules. Δεν ξέρει τίποτα για decorators.
│
├── MyApp.Application/
│ ├── Common/Cqrs/ ← ICommand, IQuery, ICommandHandler, IQueryHandler
│ ├── Common/Authorization/ ← AuthorizeByPermissionsAttribute
│ │ AuthorizationQueryHandler<,> ◄── decorator
│ │ AuthorizationCommandHandler<,> ◄── decorator
│ ├── Common/Abstractions/ ← ICurrentUser (port)
│ └── Orders/GetOrderById/ ← query + handler
│
├── MyApp.Infrastructure/
│ └── Telemetry/ ← TelemetryQueryHandler<,> ◄── decorator
│ TelemetryCommandHandler<,> ◄── decorator
│ CqrsDiagnostics (ActivitySource, Meter)
│
└── MyApp.Api/ ← controllers + composition root (Program.cs)
Γιατί έτσι:
Authorization → Application. «Ποιος επιτρέπεται να κάνει αυτό το use case» είναι κανόνας της εφαρμογής, όχι λεπτομέρεια HTTP. Θέλουμε να ισχύει είτε το use case καλείται από API είτε από background job.
Telemetry → Infrastructure. Το πώς μετράμε (OpenTelemetry, Application Insights, Datadog…) είναι τεχνική λεπτομέρεια. Το Application δεν πρέπει να ξέρει για ActivitySource ή vendor SDKs.
Το «δέσιμο» → Composition root. Το ποιος decorator τυλίγει ποιον αποφασίζεται μόνο εκεί όπου στήνεται το DI.
Παράδειγμα 1: Authorization decorator
Στόχος: δηλώνεις στο query/command τι permissions χρειάζεται, και ένας decorator το επιβάλλει πριν τρέξει ο handler. Ο handler δεν γράφει ούτε μία γραμμή για authorization.
Το attribute
namespace MyApp.Application.Common.Authorization;
///
/// Δηλώνει τα permissions που χρειάζεται ένα command ή query.
/// Ο χρήστης πρέπει να έχει τουλάχιστον ένα από τα permissions του attribute.
/// Αν υπάρχουν πολλά attributes, πρέπει να ικανοποιούνται όλα.
/// Attribute χωρίς permissions απαιτεί μόνο authenticated χρήστη.
///
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public sealed class AuthorizeByPermissionsAttribute(params string[] anyOf) : Attribute
{
public IReadOnlyCollection AnyOf { get; } = anyOf;
}
Το port για τον τρέχοντα χρήστη
Το Application δεν ξέρει τι είναι HttpContext ή JWT. Ζητάει απλώς:
namespace MyApp.Application.Common.Abstractions;
public interface ICurrentUser
{
bool IsAuthenticated { get; }
bool HasAnyPermission(IEnumerable permissions);
}
Η υλοποίηση (που διαβάζει claims από το HttpContext και permissions από τη βάση) ζει στο API/Infrastructure.
Ο «εγκέφαλος»: ένας authorizer ανά τύπο request
Το reflection είναι ακριβό, άρα το κάνουμε μία φορά ανά τύπο. Ένα static readonly πεδίο σε generic static class αρχικοποιείται μία φορά για κάθε TRequest — δωρεάν cache.
namespace MyApp.Application.Common.Authorization;
internal static class RequestAuthorizer
{
// Υπολογίζεται μία φορά ανά TRequest, όχι σε κάθε κλήση.
private static readonly AuthorizeByPermissionsAttribute[] Requirements =
[.. typeof(TRequest).GetCustomAttributes()];
public static void Authorize(ICurrentUser currentUser)
{
if (Requirements.Length == 0)
return; // το request δεν ζητάει authorization
if (!currentUser.IsAuthenticated)
throw new UnauthorizedException();
foreach (var requirement in Requirements)
{
if (requirement.AnyOf.Count > 0 && !currentUser.HasAnyPermission(requirement.AnyOf))
throw new ForbiddenException($"Missing permission for {typeof(TRequest).Name}.");
}
}
}
Οι decorators
Επειδή έχουμε δύο interfaces (IQueryHandler και ICommandHandler), χρειαζόμαστε δύο μικρούς decorators. Η λογική είναι στον RequestAuthorizer, οπότε δεν υπάρχει διπλή λογική — μόνο δύο «κελύφη».
namespace MyApp.Application.Common.Authorization;
public sealed class AuthorizationQueryHandler(
IQueryHandler inner,
ICurrentUser currentUser) : IQueryHandler
where TQuery : IQuery
{
public Task HandleAsync(TQuery request, CancellationToken cancellationToken = default)
{
RequestAuthorizer.Authorize(currentUser); // πριν — μπορεί να κάνει short-circuit
return inner.HandleAsync(request, cancellationToken);
}
}
public sealed class AuthorizationCommandHandler(
ICommandHandler inner,
ICurrentUser currentUser) : ICommandHandler
where TCommand : ICommand
{
public Task HandleAsync(TCommand request, CancellationToken cancellationToken = default)
{
RequestAuthorizer.Authorize(currentUser);
return inner.HandleAsync(request, cancellationToken);
}
}
Παρατήρησε τους 5 κανόνες: ίδιο interface ✅, inner από constructor ✅, καλεί το inner (ή κάνει σκόπιμα short-circuit με exception) ✅, δεν αλλάζει το συμβόλαιο ✅, κάνει ένα πράγμα ✅.
Από exception σε HTTP status
Ο decorator πετάει σημασιολογικά exceptions (UnauthorizedException, ForbiddenException) που ζουν στο Application. Το API τα μεταφράζει σε HTTP — έτσι το Application δεν ξέρει τι είναι 401/403:
internal sealed class GlobalExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler
{
public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
var (status, title) = exception switch
{
UnauthorizedException => (StatusCodes.Status401Unauthorized, "Unauthorized"),
ForbiddenException => (StatusCodes.Status403Forbidden, "Forbidden"),
NotFoundException => (StatusCodes.Status404NotFound, "Not found"),
_ => (StatusCodes.Status500InternalServerError, "Unexpected error")
};
httpContext.Response.StatusCode = status;
return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = { Status = status, Title = title },
Exception = exception
});
}
}
⚠️ Το authorization σε επίπεδο use case δεν αντικαθιστά το authentication στο HTTP pipeline ([Authorize], RequireAuthorization()). Είναι δεύτερη γραμμή άμυνας, κοντά στη λογική. Κράτα και τα δύο.
Παράδειγμα 2: Telemetry decorator με OpenTelemetry
Στόχος: κάθε command και query να εμφανίζεται ως span στο distributed tracing (με διάρκεια, επιτυχία/αποτυχία) και να μετριέται σε ένα histogram διάρκειας — χωρίς ούτε μία γραμμή στους handlers.
Χρησιμοποιούμε τα built-in APIs του .NET (System.Diagnostics.ActivitySource και Meter). Είναι vendor-neutral: το OpenTelemetry τα στέλνει σε Application Insights, Grafana, Jaeger, Datadog ή ό,τι άλλο θέλεις.
Οι πηγές telemetry
namespace MyApp.Infrastructure.Telemetry;
public static class CqrsDiagnostics
{
public const string SourceName = "MyApp.Cqrs";
internal static readonly ActivitySource ActivitySource = new(SourceName);
private static readonly Meter Meter = new(SourceName);
internal static readonly Histogram<double> RequestDuration = Meter.CreateHistogram<double>(
name: "myapp.cqrs.request.duration",
unit: "ms",
description: "Duration of CQRS command and query handlers.");
}
Ένας κοινός «πυρήνας» για commands και queries
Και πάλι: δύο interfaces → δύο decorators. Για να μη γράψουμε τη λογική δύο φορές, τη βάζουμε σε έναν helper:
namespace MyApp.Infrastructure.Telemetry;
internal static class CqrsTelemetry
{
public static async Task TrackAsync(
string kind, // "command" ή "query"
Func> handle)
{
var requestName = typeof(TRequest).Name;
using var activity = CqrsDiagnostics.ActivitySource.StartActivity($"{kind} {requestName}");
activity?.SetTag("cqrs.kind", kind);
activity?.SetTag("cqrs.request", requestName);
var startedAt = Stopwatch.GetTimestamp();
var outcome = "success";
try
{
return await handle();
}
catch (Exception ex)
{
outcome = "failure";
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.AddException(ex); // .NET 9+
throw; // ΠΟΤΕ δεν «τρώμε» το exception
}
finally
{
CqrsDiagnostics.RequestDuration.Record(
Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds,
new KeyValuePair<string, object?>("cqrs.kind", kind),
new KeyValuePair<string, object?>("cqrs.request", requestName),
new KeyValuePair<string, object?>("cqrs.outcome", outcome));
}
}
}
Λίγα πράγματα που αξίζει να προσέξει ένας junior:
using var activity — το span κλείνει αυτόματα στο τέλος της μεθόδου, ακόμα και με exception.
activity?. — το StartActivity επιστρέφει null όταν κανείς δεν ακούει (π.χ. tracing απενεργοποιημένο). Το ?. κάνει τον κώδικα σχεδόν μηδενικού κόστους σε αυτή την περίπτωση.
throw; και όχι throw ex; — κρατάει το αρχικό stack trace.
finally — η διάρκεια μετριέται και σε επιτυχία και σε αποτυχία.
Tags με χαμηλό cardinality — βάζουμε το όνομα του request, ποτέ το id της παραγγελίας ή το email του χρήστη. Τα metrics με «άπειρες» τιμές tags γονατίζουν το monitoring σύστημα (και μπορεί να διαρρεύσουν προσωπικά δεδομένα).
Οι decorators
Τώρα οι decorators είναι από μία γραμμή:
namespace MyApp.Infrastructure.Telemetry;
public sealed class TelemetryQueryHandler(
IQueryHandler inner) : IQueryHandler
where TQuery : IQuery
{
public Task HandleAsync(TQuery request, CancellationToken cancellationToken = default)
=> CqrsTelemetry.TrackAsync("query", () => inner.HandleAsync(request, cancellationToken));
}
public sealed class TelemetryCommandHandler(
ICommandHandler inner) : ICommandHandler
where TCommand : ICommand
{
public Task HandleAsync(TCommand request, CancellationToken cancellationToken = default)
=> CqrsTelemetry.TrackAsync("command", () => inner.HandleAsync(request, cancellationToken));
}
Ενεργοποίηση του OpenTelemetry
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddSource(CqrsDiagnostics.SourceName)) // ← τα spans των handlers
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddMeter(CqrsDiagnostics.SourceName)) // ← το histogram
.UseOtlpExporter(); // ή Azure Monitor exporter κ.λπ.
Το αποτέλεσμα σε ένα trace:
GET /api/orders/42 38 ms
└── query GetOrderByIdQuery 31 ms cqrs.kind=query
└── SQL SELECT ... FROM Orders 24 ms
💡 Αν η ομάδα σου χρησιμοποιεί vendor SDK (π.χ. TelemetryClient του Application Insights), το pattern μένει ακριβώς το ίδιο: ο decorator παίρνει ένα δικό σου interface (π.χ. IApplicationTelemetry) αντί για ActivitySource. Η αλλαγή vendor αγγίζει μόνο την υλοποίηση αυτού του interface — όχι τους decorators, και σίγουρα όχι τους handlers.
Registration στο DI χωρίς Scrutor
Εδώ είναι το σημείο που μπερδεύει τους περισσότερους. Το built-in DI container του .NET δεν έχει έτοιμο Decorate(). Έχεις δύο επιλογές:
τη βιβλιοθήκη Scrutor (services.Decorate(typeof(IQueryHandler<,>), typeof(TelemetryQueryHandler<,>))),
ένα μικρό δικό σου extension. Είναι ~40 γραμμές και θα καταλάβεις ακριβώς τι γίνεται — ας το γράψουμε.
Η ιδέα
Το IServiceCollection είναι απλώς μια λίστα από ServiceDescriptor («για τον τύπο X, φτιάξε το Y με lifetime Z»). Για να διακοσμήσουμε:
βρίσκουμε κάθε descriptor για IQueryHandler,
τον αντικαθιστούμε με έναν νέο descriptor για τον ίδιο τύπο, που με ένα factory:
φτιάχνει το αρχικό αντικείμενο (το inner),
φτιάχνει τον decorator δίνοντάς του το inner.
Ο κώδικας
namespace MyApp.Application.Common.DependencyInjection;
public static class DecorationExtensions
{
///
/// Τυλίγει κάθε registered κλειστό generic service του
/// (π.χ. IQueryHandler<,>) με τον .
/// Καλείται ΜΕΤΑ το registration των handlers. Ο decorator που καλείται τελευταίος γίνεται ο εξωτερικός.
///
public static IServiceCollection DecorateAll(
this IServiceCollection services,
Type openServiceType,
Type openDecoratorType)
{
for (var i = 0; i < services.Count; i++)
{
var descriptor = services[i];
if (!IsClosedVersionOf(descriptor, openServiceType))
continue;
var serviceType = descriptor.ServiceType;
var decoratorType = openDecoratorType.MakeGenericType(serviceType.GetGenericArguments());
services[i] = ServiceDescriptor.Describe(
serviceType,
provider => ActivatorUtilities.CreateInstance(
provider,
decoratorType,
CreateInner(provider, descriptor)), // ← το inner περνάει ως παράμετρος
descriptor.Lifetime); // ← ίδιο lifetime με τον αρχικό
}
return services;
}
private static bool IsClosedVersionOf(ServiceDescriptor descriptor, Type openServiceType) =>
!descriptor.IsKeyedService &&
descriptor.ServiceType.IsGenericType &&
!descriptor.ServiceType.ContainsGenericParameters &&
descriptor.ServiceType.GetGenericTypeDefinition() == openServiceType;
// Ο αρχικός descriptor μπορεί να είναι type, instance ή factory (π.χ. ήδη decorated).
private static object CreateInner(IServiceProvider provider, ServiceDescriptor descriptor)
{
if (descriptor.ImplementationInstance is not null)
return descriptor.ImplementationInstance;
if (descriptor.ImplementationFactory is not null)
return descriptor.ImplementationFactory(provider);
return ActivatorUtilities.CreateInstance(provider, descriptor.ImplementationType!);
}
}
Το ActivatorUtilities.CreateInstance(provider, decoratorType, inner) κάνει το εξής έξυπνο: ό,τι παράμετρο του constructor ταιριάζει με το inner που του δίνουμε, τη γεμίζει με αυτό· τις υπόλοιπες (π.χ. ICurrentUser) τις παίρνει από το container.
Το registration ανά layer
// MyApp.Application/DependencyInjection.cs
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddScoped, GetOrderByIdQueryHandler>();
services.AddScoped, CreateOrderCommandHandler>();
// ... όλοι οι handlers (ή assembly scanning)
// Authorization: ο εσωτερικός decorator, ακριβώς γύρω από τον handler.
services.DecorateAll(typeof(IQueryHandler<,>), typeof(AuthorizationQueryHandler<,>));
services.DecorateAll(typeof(ICommandHandler<,>), typeof(AuthorizationCommandHandler<,>));
return services;
}
// MyApp.Infrastructure/DependencyInjection.cs
public static IServiceCollection AddCqrsTelemetry(this IServiceCollection services)
{
services.DecorateAll(typeof(IQueryHandler<,>), typeof(TelemetryQueryHandler<,>));
services.DecorateAll(typeof(ICommandHandler<,>), typeof(TelemetryCommandHandler<,>));
return services;
}
// MyApp.Api/Program.cs — composition root
builder.Services
.AddApplication() // handlers + authorization
.AddInfrastructure()
.AddCqrsTelemetry(); // ΤΕΛΕΥΤΑΙΟ → εξωτερικός decorator
💡 Γιατί decorate όλους τους handlers με authorization, ακόμα κι αν δεν έχουν attribute; Επειδή ο RequestAuthorizer κάνει απλώς return όταν δεν υπάρχει attribute (κόστος: ένας έλεγχος μήκους πίνακα). Έτσι δεν μπορείς να ξεχάσεις να καταχωρήσεις κάποιον handler «με authorization». Η εναλλακτική — ρητό registration ανά handler, π.χ. AddAuthorizedHandler() — δουλεύει κι αυτή, αλλά τότε χρειάζεσαι ένα architecture test που να εγγυάται ότι κανείς δεν ξεχάστηκε (δες Testing).
Η σειρά των decorators μετράει
Με το παραπάνω registration, όταν ο controller ζητήσει IQueryHandler παίρνει:
Controller
│
▼
TelemetryQueryHandler ← εξωτερικός: ξεκινά span + χρονόμετρο
│
▼
AuthorizationQueryHandler ← ελέγχει permissions (μπορεί να σταματήσει εδώ)
│
▼
GetOrderByIdQueryHandler ← business logic
Γιατί telemetry έξω από authorization; Επειδή θέλουμε να βλέπουμε και τις απορρίψεις: ένα ξαφνικό κύμα από ForbiddenException στο dashboard είναι πολύτιμη πληροφορία (λάθος deployment permissions ή κάποιος που «δοκιμάζει»). Αν το authorization ήταν απέξω, τα απορριφθέντα requests δεν θα εμφανίζονταν ποτέ στο telemetry.
Ένας γενικός κανόνας για τη σειρά, από έξω προς τα μέσα:
Θέση Decorator Γιατί εκεί
1 (εξωτερικός) Telemetry / Logging Να καταγράφει τα πάντα, και τις αποτυχίες των εσωτερικών.
2 Authorization Να απορρίπτει όσο πιο νωρίς γίνεται, πριν από ακριβή δουλειά.
3 Validation Δεν έχει νόημα να κάνεις validate κάτι που ο χρήστης δεν επιτρέπεται να κάνει.
4 Caching (μόνο queries) Μετά το authorization — ποτέ μην επιστρέψεις cached δεδομένα σε χρήστη χωρίς δικαίωμα!
5 Transaction / Unit of Work (μόνο commands) Όσο πιο κοντά στον handler.
(εσωτερικός) Handler Business logic.
Testing
Ένα από τα μεγάλα δώρα του pattern: κάθε κομμάτι τεστάρεται μόνο του.
-
Ο handler — χωρίς authorization, χωρίς telemetry
[Test]
public async Task Returns_Order_When_It_Exists()
{
var repository = new InMemoryOrderRepository(new Order(42, "Pending"));
var handler = new GetOrderByIdQueryHandler(repository);var result = await handler.HandleAsync(new GetOrderByIdQuery(42));
Assert.That(result.Id, Is.EqualTo(42));
}
Κανένα mock για current user ή telemetry. Ο handler δεν τα ξέρει καν. -
Ο decorator — με ψεύτικο inner
public class AuthorizationQueryHandlerTests
{
[AuthorizeByPermissions("orders.read")]
private sealed record ProtectedQuery : IQuery;private sealed class SpyHandler : IQueryHandler
{
public bool WasCalled { get; private set; }public Task<int> HandleAsync(ProtectedQuery request, CancellationToken cancellationToken = default) { WasCalled = true; return Task.FromResult(1); }}
[Test]
public void Throws_Forbidden_And_Does_Not_Call_Inner_When_Permission_Is_Missing()
{
var inner = new SpyHandler();
var sut = new AuthorizationQueryHandler(
inner, new FakeCurrentUser(isAuthenticated: true, permissions: []));Assert.ThrowsAsync<ForbiddenException>(() => sut.HandleAsync(new ProtectedQuery())); Assert.That(inner.WasCalled, Is.False);}
[Test]
public async Task Calls_Inner_When_Permission_Is_Granted()
{
var inner = new SpyHandler();
var sut = new AuthorizationQueryHandler(
inner, new FakeCurrentUser(isAuthenticated: true, permissions: ["orders.read"]));await sut.HandleAsync(new ProtectedQuery()); Assert.That(inner.WasCalled, Is.True);}
} Ο telemetry decorator — ακούγοντας το ActivitySource
Στην ίδια test class, ξαναχρησιμοποιώντας τα ProtectedQuery και SpyHandler:
[Test]
public async Task Records_A_Span_For_Each_Query()
{
var stopped = new List();
using var listener = new ActivityListener
{
ShouldListenTo = source => source.Name == CqrsDiagnostics.SourceName,
Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStopped = stopped.Add
};
ActivitySource.AddActivityListener(listener);
var sut = new TelemetryQueryHandler<ProtectedQuery, int>(new SpyHandler());
await sut.HandleAsync(new ProtectedQuery());
Assert.That(stopped.Select(a => a.DisplayName), Does.Contain("query ProtectedQuery"));
}
- Το wiring — ότι το DI στήνει πραγματικά την αλυσίδα Τα παραπάνω tests περνάνε ακόμα κι αν ξεχάσεις να καλέσεις το DecorateAll. Γι' αυτό χρειάζεσαι κι ένα test για τη σύνθεση:
[Test]
public void Query_Handlers_Are_Wrapped_With_Telemetry_Then_Authorization()
{
var services = new ServiceCollection();
services.AddScoped(_ => new FakeCurrentUser(isAuthenticated: true, permissions: []));
services.AddScoped(_ => new InMemoryOrderRepository());
services.AddApplication();
services.AddCqrsTelemetry();
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var handler = scope.ServiceProvider.GetRequiredService<IQueryHandler<GetOrderByIdQuery, OrderDto>>();
Assert.That(handler, Is.TypeOf<TelemetryQueryHandler<GetOrderByIdQuery, OrderDto>>());
}
Και ένα architecture test (π.χ. με NetArchTest ή απλό reflection) που αποτυγχάνει αν υπάρχει command/query με [AuthorizeByPermissions] του οποίου ο handler δεν περνάει από authorization decorator. Αυτό είναι το δίχτυ ασφαλείας που κάνει το «ξέχασα να το καταχωρήσω» build error αντί για security incident.
Παγίδες που θα συναντήσεις
Λάθος σειρά registration. Ο decorator που εφαρμόζεται τελευταίος είναι ο εξωτερικός. Αν καλέσεις AddCqrsTelemetry() πριν από AddApplication(), δεν θα υπάρχουν ακόμα handlers να διακοσμήσει — και δεν θα πάρεις κανένα error. Γράψε το test του wiring.
Lifetimes και captive dependencies. Ο decorator κληρονομεί το lifetime του αρχικού descriptor. Αν ο handler είναι Scoped και ο decorator ζητάει ICurrentUser (επίσης Scoped), όλα καλά. Αν όμως κάποτε κάνεις handler Singleton, ο decorator θα «κρατήσει» για πάντα το πρώτο ICurrentUser που θα πάρει — δηλαδή τον πρώτο χρήστη που έκανε request. Κράτα τους handlers Scoped και άφησε το ValidateScopes = true (default σε Development) να σε προστατεύει.
Το inner δεν γίνεται dispose από το container. Ό,τι επιστρέφει το factory (ο decorator) γίνεται dispose από το container. Το inner όμως που φτιάχνει το ActivatorUtilities.CreateInstance όχι. Συνήθως οι handlers δεν είναι IDisposable, οπότε δεν σε απασχολεί. Αν είναι, κάνε register τον concrete τύπο στο container και πάρ' τον με GetRequiredService (όπως κάνει και το Scrutor).
Open generics στο built-in container. Το services.AddScoped(typeof(IQueryHandler<,>), typeof(TelemetryQueryHandler<,>)) δεν κάνει decoration — απλώς καταχωρεί ακόμα μία υλοποίηση, που θα χρειαζόταν τον εαυτό της ως inner. Γι' αυτό το DecorateAll δουλεύει πάνω στους ήδη κλειστούς τύπους.
Καταπιεσμένα exceptions. Ένας decorator που κάνει catch χωρίς throw αλλάζει το συμβόλαιο: ο controller νομίζει ότι όλα πήγαν καλά. Logging/telemetry decorators πάντα ξαναπετάνε.
Ξεχασμένο CancellationToken. Ο decorator πρέπει να περνάει το cancellationToken στο inner. Αν το ξεχάσεις, η ακύρωση ενός request σταματάει στον decorator και η βάση συνεχίζει να δουλεύει για κάποιον που έχει ήδη φύγει.
Business logic σε decorator. Αν βρεις τον εαυτό σου να γράφει if (request is CreateOrderCommand cmd && cmd.Total > 1000) μέσα σε decorator, σταμάτα. Αυτό είναι business κανόνας και ανήκει στο Domain ή στον handler.
Πιο βαθιά stack traces. Κάθε decorator είναι ένα επίπεδο ακόμα στο stack trace. Είναι το τίμημα — και αξίζει. Χρησιμοποίησε ονόματα που φαίνονται καθαρά (TelemetryQueryHandler, όχι Wrapper1).
Κόστος reflection. Το reflection στο DecorateAll γίνεται μία φορά στο startup. Το reflection για attributes γίνεται μία φορά ανά τύπο χάρη στο static readonly στο generic class. Σε runtime, κάθε decorator κοστίζει μια επιπλέον κλήση μεθόδου — αμελητέο μπροστά σε ένα SQL query.
Checklist
Πριν κάνεις merge έναν νέο decorator:
Υλοποιεί ακριβώς το ίδιο interface με αυτό που τυλίγει.
Παίρνει το inner από constructor ως interface.
Περνάει το CancellationToken στο inner.
Δεν «τρώει» exceptions — ή, αν κάνει short-circuit, το κάνει ρητά και με σημασιολογικό exception.
Κάνει ένα πράγμα και δεν περιέχει business λογική.
Ζει στο σωστό layer (Application για κανόνες εφαρμογής, Infrastructure για τεχνικά θέματα).
Η σειρά του στην αλυσίδα είναι σκόπιμη και τεκμηριωμένη.
Έχει unit tests με ψεύτικο inner.
Υπάρχει test που επιβεβαιώνει ότι το DI τον εφαρμόζει πραγματικά.
Κλείνοντας
Το Decorator pattern λύνει ένα πολύ συγκεκριμένο και πολύ συχνό πρόβλημα: πώς προσθέτω συμπεριφορά γύρω από κάτι, χωρίς να το αλλάξω και χωρίς να πολλαπλασιάσω κλάσεις. Η ιδέα χωράει σε μία πρόταση: υλοποίησε το ίδιο interface, κράτα ένα inner, κάνε τη δουλειά σου πριν ή μετά.
Σε μια Clean Architecture με CQRS, αυτή η μία πρόταση σου δίνει:
handlers που περιέχουν μόνο business logic,
authorization που δεν μπορείς να ξεχάσεις,
telemetry σε κάθε use case, χωρίς ούτε μία γραμμή στους handlers,
ένα pipeline που ελέγχεις πλήρως — χωρίς MediatR, χωρίς «μαγεία», με ~40 γραμμές δικού σου κώδικα.
Την επόμενη φορά που θα δεις το ίδιο try/catch/finally σε δέκατο αρχείο, ξέρεις τι να κάνεις.
Αν σου φάνηκε χρήσιμο, πες μου στα σχόλια ποιον decorator θα έγραφες πρώτο στο δικό σου project — validation, caching ή transactions;
Top comments (0)