Own mediator

V súčasnosti sa často používa návrhový vzor Mediator. Jeho cieľom je oddeliť komunikáciu medzi objektami od seba navzájom. Čo zníži závislosti medzi triedami, sprehľadní kód a zjednoduší testovanie.
V .NET prostredí vývojár často siahne po super knižnici MediatR od Jimmyho Bogarda. Táto knižnica poskytuje jednoduchý spôsob ako implementovať Mediator do aplikácie.

Sú však situácie kedy nemôžeme / nechceme využiť knižnicu tretích strán. Napríklad píšete nejakú vlastnú knižnicu / framework a nechcete do nej zavádzať takéto závislosti.

Niečo podobné sa stalo aj nám. Našťastie sme nepotrebovali komplet funkčnosť akú poskytuje MediatR, ale stačilo nám len základné odoslanie udalosti a jej spracovanie.

Preto teraz ukážem ako si jednoducho vytvoriť takúto implementáciu vlastného Mediatoru.

Začnime s definovaním rozhrania správy. V našom prípade sa jednalo o doménovú udalosť, preto ten názov.

public interface IDomainEvent
{
}

Je to len čisto značkovacie rozhranie. Zaobišli by sme sa aj bez neho, ale je to dobrý spôsob ako označiť, že ide o udalosť.

Následne rozhranie pre spracovanie udalostí.

public interface IDomainEventHandler<TEvent> 
    where TEvent : IDomainEvent
{
    Task HandleAsync(TEvent domainEvent, CancellationToken cancellationToken = default);
}

Potrebujeme ešte rozhranie pre odosielanie udalostí.

public interface IEventPublisher
{
    Task PublishAsync<TEvent>(TEvent domainEvent, CancellationToken cancellationToken = default)
        where TEvent : IDomainEvent;
}

Tu by sme s abstrakciami mohli skončiť a začať implementovať. My sme sa však rozhodli pridať tam ešte abstrakciu pre stratégiu spracovania.
Nateraz nám stačilo postupné spracovanie udalostí, ale v budúcnosti by sme mohli potrebovať aj iné stratégie.

public interface IEventPublisherStrategy
{
    Task PublishAsync<TEvent>(
        IEnumerable<IDomainEventHandler<TEvent>> handlers,
        TEvent domainEvent,
        CancellationToken cancellationToken)
        where TEvent : IDomainEvent;
}

Implementácia

Publisher môže vyzerať napríklad takto:

internal class EventPublisher : IEventPublisher
{
    private readonly IServiceProvider _serviceProvider;
    private readonly IEventPublisherStrategy _publisherStrategy;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public EventPublisher(
        IServiceProvider serviceProvider,
        IHttpContextAccessor httpContextAccessor,
        IEventPublisherStrategy publisherStrategy)
    {
        _serviceProvider = serviceProvider;
        _publisherStrategy = publisherStrategy;
        _httpContextAccessor = httpContextAccessor;
    }

    public async Task PublishAsync<TEvent>(TEvent domainEvent, CancellationToken cancellationToken = default)
        where TEvent : IDomainEvent
    {
        IEnumerable<IDomainEventHandler<TEvent>> handlers;
        if (_httpContextAccessor.HttpContext is not null)
        {
            // 👇 if available, then find handlers in HttpContext services
            handlers = GetHandlers<TEvent>(_httpContextAccessor.HttpContext.RequestServices);
            // 👇 publish event to all handlers
            await _publisherStrategy.PublishAsync(handlers, domainEvent, cancellationToken);
        }
        else
        {
            // 👇 if not, then create new scope
            using var scope = _serviceProvider.CreateScope();
            handlers = GetHandlers<TEvent>(scope.ServiceProvider);
            // 👇 publish event to all handlers
            await _publisherStrategy.PublishAsync(handlers, domainEvent, cancellationToken);
        }
    }

    private static IEnumerable<IDomainEventHandler<TEvent>> GetHandlers<TEvent>(IServiceProvider serviceProvider)
        where TEvent : IDomainEvent
        => serviceProvider.GetServices<IDomainEventHandler<TEvent>>();
}

Publisher sa stará o získanie handlerov z kontajnera a následne ich prepošle na publisher strategy, ktorá rozhodne o tom ako budú spracované.

public sealed class ForeachAwaitPublisherStrategy : IEventPublisherStrategy
{
    public async Task PublishAsync<TEvent>(
        IEnumerable<IDomainEventHandler<TEvent>> handlers,
        TEvent domainEvent,
        CancellationToken cancellationToken)
        where TEvent : IDomainEvent
    {
        // 👇 publish event to all handlers
        foreach (var handler in handlers)
        {
            await handler.HandleAsync(domainEvent, cancellationToken);
        }
    }
}

Náš ForeachAwaitPublisherStrategy spracuje udalosti postupne. Správu posunie všetkým handlerom a počká na ich spracovanie.

Registrácia

Aby sme si mohli vytvorené abstrakcie použiť, musíme ich zaregistrovať v DI kontajneri.

public static IServiceCollection AddEventPublisher(this IServiceCollection services)
{
    services.AddHttpContextAccessor();
    services.TryAddSingleton<IEventPublisher, EventPublisher>();
    services.TryAddSingleton<IEventPublisherStrategy, ForeachAwaitPublisherStrategy>();
    return services;
}

Rovnako si môžeme vytvoriť extension aj na jednoduché zaregistrovanie handlerov.

public static IServiceCollection AddDomainEventHandler<TEvent, THandler>(
    this IServiceCollection services,
    ServiceLifetime lifetime = ServiceLifetime.Transient)
    where TEvent : IDomainEvent
    where THandler : class, IDomainEventHandler<TEvent>
{
    services.Add(new ServiceDescriptor(typeof(IDomainEventHandler<TEvent>), typeof(THandler), lifetime));

    return services;
}

Použitie tejto extension vyzerá nasledovne services.AddDomainEventHandler<YourEvent, YourHandler>();.
Je to z dôvodu, že som sa chcel vyhnúť reflexii. Pokiaľ ti reflexia neprekáža, tak sa dá dosiahnúť aj zjednodušené volanie services.AddDomainEventHandler<YourHandler>();.
To ale nechám na teba 😉.

Použitie

Nakoniec si ukážeme ako použiť náš vlastný Mediator.

Udalosť a handler:

// 👇 The event that the product was created
public record ProductCreated(int Id, string Name, decimal Price) : IDomainEvent;

// 👇 The handler that will process the event
public class ProductCreatedHandler : IDomainEventHandler<ProductCreated>
{
    public Task HandleAsync(ProductCreated domainEvent, CancellationToken cancellationToken = default)
    {
        // 👇 Process the event
        Console.WriteLine($"Product created: {domainEvent.Name}");
        return Task.CompletedTask;
    }
}

A registrácia:

builder.Services.AddEventPublisher();
builder.Services.AddDomainEventHandler<ProductCreated, ProductCreatedHandler>();

Použitie v endpointe:

app.MapPost("/products", async (Product product, IEventPublisher publisher) =>
{
    // Save the product
    // 👇 Publish the event
    await publisher.PublishAsync(new ProductCreated(product.Id, product.Name, product.Price));
    return product;
});

Odkazy