Čo po MediatR? Zamyslienie sa nad architektúrov vo svete s Minimal API

TLDR: Hľadám akú architektúru/organizáciu kódu použiť v projektoch s Minimal API v prevažne CRUP aplikácii (e-Shop). MediatR ukázal dobrý smer, ale s Minmal API by sme sa mali architektonicky posunúť, ale kam?

Dlhý obkec:

Skúšam si HTMX v kombinácii s Minimal API, PicoCSS a Razor komponentami na klone reálneho e-shopu (Martinus).

Kód som štruktúroval tak, že som adresár so stránkou a všetkými jej invektívnymi komponetami, co ma viedlo k myšlienke použiť vertical slice architektúru.

V projektoch, kde mam kontrolery pre API, alebo aj stránky zo statickým renderovaním som úspešne používal servisnú architektúru (IBasketService, IBookManager,…),
tento prístup mi vyhovoval, lebo súvisiaca logika bola na jednom mieste, zdieľaný kód bol v privátnych metódach, v kontrolleroch sa to používalo prirodzene. No mám pocit, že tento prístup sa nehodí k Minimal API, hlavne, keď tých služieb potrebujem v endpointe viac.

Na architektúre používanej pri MediatR (alebo MediatR like knižníc - neimplementujú CQRS, ale určujú spôsob štruktúrovania kódu) mi vadí niekoľko vecí:

  • Runtime binding - v podstate hádam parameter a návratovú hodnotu, proste by som chcel viac typové riešenie.

  • Do delegáta v Minimal API by som chcel dávať niečo špecifickejšie ako len IMediator (má to pach ako service lokátor) - skôr IMediator<SpecificHanlder> (už ste niekedy menili implementaciu handlera kvôli testom?) alebo IMediator<ISpecificHandler> - takmer vždy sa volá len jedna metóda.

  • Nie je jasne ako jednoducho zdieľať kód medzi rôznymi handlermi.

  • (osobná skúsenosť) Pri použití MediatR vidím jeho výhody, no súčasne mám pocit, že niečo z architektonického hľadiska nerobím dobre.

Hľadám akú architektúru/organizáciu kódu použiť v projektoch s Minimal API v prevažne CRUP aplikácii (e-Shop). Nejde mi ani tak o Clean Architekturu, ktora riesi trochu iné veci, ale práve architektúru medzi vrstvov Minimal API a biznis logikou.

V podstate riesim ako by mal vyzerat MediatR, keby vznikol dnes, ale cistejsie, lepsie, krajsie.

Túto otázku som polozil aj na redite: https://www.reddit.com/r/dotnet/comments/1lcux41/beyond_mediatr/

IMediator<SpecificHandler> - aky je s tym vlastne problem? Na to nieje treba ziadnu knizcnici, ci mi nieco unika?

IMediator<ISpecificHandler> - a co by to akoze malo robit? Preco nie len ISpecificHandler?

Cely ten moj prispevok treba brat, ako nejake brainstormovanie, kde sa snazim prist na to, ako robit lepsie vertical slice architekturu v kontexte dnesnej doby (MediatR vznikol pred 10-timi rokmi). No celkom nemam utriedene myslienky.

IMediator<SpecificHandler> - aky je s tym vlastne problem? Na to nieje treba ziadnu knizcnici, ci my nieco unika?

IMediator<ISpecificHandler> - a co by to akoze malo robit? Preco nie len ISpecificHandler?

MediatR pridava este pipeline/behoviar proste globalne pre a post procesory, ktore idu vyuzit na logovanie, tranzakcie a error handling.
Prlus rozhranie IRequestHandler unifikuje sposob oganizacie kodu.

Obe tieto veci by som chcel zachovat, ale cele to mat viac typove a semanticke, aby som z pohladu na MinimalAPI vedel co volam.

Asi si skusim nakodit nejaku implementaciu vramci toho eshop projektu, len som chcel pocut pohlad inych ludi na takuto architektonicku volbu, pripadne co na MediatR “opravit”.

A co realne davas do “pre a post procesorov”, ked sa bavime o kanaly medzi API a BL?
ASP.NET Core uz predsa ma request pipeline a robit dalsiu pipeline mi pride prilis prespekulovane.

Ja som skor zastanca nejakej base classy CommandHandlerBase<TCommand>, alebo RequestHandlerBase<TRequest>, kde mozes robit spolocne logovanie a error handling.

No, jednoducho sa treba spytat, ci to stoji za to. Ci ked sa na to pozrie cudzi clovek bude potrebovat vysvetlenie, alebo to dokaze hned pochopit.

Mimochodom, odkedy poznam TestContainers, tak pisem len integracne API testy a nemockujem dependencies v API

EDIT: ked tak nad tym rozmyslam, mozno by som taku pipeline zvazil v Blazor projekte, kedze Blazor request pipeline nema. V ramci nej by som volal napr telemetryClient.StartOperation().., aby som mal kolerovane traces, exception a dependecy telemetry s danou operaciou.

A co realne davas do “pre a post procesorov”, ked sa bavime o kanaly medzi API a BL?

Mal som takto postavene len dve hoby apky a v kazdej bolo logovanie a cache.

No napriklad, v niektorych, co som robil v praci a boli postavene na servisnej architekture, tak som tam cez AOP dorabal perfomace logovanie cez MiniProfiler ako plugin.

ASP.NET Core uz predsa ma request pipeline a robit dalsiu pipeline mi pride prilis prespekulovane.

Tento argument som uz pocul a v podstate s nim suhlasim. Myslim, ze pri REST API plati na 100%, no ked uz mas aj gRPC,WCF endpoint, background workera, hangfire job, tak uz potrebujes nieco dalsie.
Ja s tym mam aj filozoficky problem - ASP.NET Core pipeline je na spracovanie HTTP requestu, MediatR pipeline handluje uz bussines logiku.

Ja som skor zastanca nejakej base classy CommandHandlerBase<TCommand> , alebo RequestHandlerBase<TRequest> , kde mozes robit spolocne logovanie a error handling.

Ano, len pri tych cros cutting zalezitostiach je to take… predstv si, ze tam chces po nejakom case pridat profiling a tym pridas base triede zavislost a v tom momente sa ti zmeni stovka konstruktorov v aplikacii.

No, jednoducho sa treba spytat, ci to stoji za to. Ci ked sa na to pozrie cudzi clovek bude potrebovat vysvetlenie, alebo to dokaze hned pochopit.

To je jeden s problemov, ktory z MediatR mam (plus to, ze MediatR neimplementuje mediator). Ale uz mam v hlave nejake napady.

Toto sa da vyriesit tak, ze nebudes injectovat jednotlive servicy, ale nejaku common classu, napr:

public class CommandHandlerBase(CommandHandlerContext context);

Ja som inak rozmyslal aj nad eventami

public interface ICommandHandler<TCommand>
{
   event Action<TCommand> Executing;
   event Action<TCommand> Executed;
}

serviceColection.AddScoped<ICommandHandler<SpecificCommand>>(sp => 
{
    var handler = ActivatorUtilities.CreateInstance<SpecificCommandHandler>(sp);
    SetupLogging(handler, sp);
    //samozrejme ide to spravit aj genericky pre vsetky commandy naraz
})  

To je jeden s problemov, ktory z MediatR mam

Inak, kedze MediatR zacina byt notoricky znamy. Ak je v readme.md popisane, ako sa pouziva, napriklad ze v pipeline je cachovanie, tak by s tym vacsina developerov asi nemama mat problem.

Ono by to asi chcelo nejake Debugger api pre autorov kniznic, ktore by ulahcilo StepInto, pripadne nieco ako Go To Definition, ale islo by to do handlera. Hodilo by sa to napriklad aj pri Reactive Extension pipelines.

To by bolo tiez riesnie a dana magia by sa riesila v danej clase. No stale to miesa infrastrukturny kod a bussines logiku.

No s tymi eventami je to pekne.

//samozrejme ide to spravit aj genericky pre vsetky commandy naraz

Ak mi vies z hlavy napisat ukazku, tak budem rad. Lebo o nieco taketo som sa pokusal davnejsie - mat factory metodu v Microsoft DI, ktora ma genericku registraciu.


Ja som vcera v noci skusal implementacie a zatial som dospel k niecomu takemuto:

public class GetAuthorByIdHandler : IUseCaseHandler<int, AuthorData>
{
    private StoryVaultContext storyVaultContext;

    public GetAuthorByIdHandler(StoryVaultContext storyVaultContext)
    {
        this.storyVaultContext = storyVaultContext;
    }

    public async ValueTask<AuthorData> Handle(int request, CancellationToken cancellationToken = default)
    {
        return await ...
    }
}

A potom MinimalAPi vyzera:

app.MapGet("/author/{id}", async (int id, 
    IUseCase<GetAuthorByIdHandler> getAuthorbyId,
    IUseCase<GetBooksByAuthorHanler> getBookByAuthor,
    CancellationToken cancellationToken) =>
{
    AuthorData author = await getAuthorbyId.Invoke<GetAuthorByIdHandler, int, AuthorData>(id, CancellationToken.None);
    IList<BookRecord> books = await getBookByAuthor.Invoke<GetBooksByAuthorHanler, GetBooksByAuthorQuery, IList<BookRecord>>(new GetBooksByAuthorQuery(id, 1, 25), cancellationToken);

    return new RazorComponentResult<TheStoryVault.Pages.Author.MainView>(new Dictionary<string, object?>()
    {
        {"Author", author },
        {"Books", books }
    });
});

Alebo druha moznost:

app.MapGet("/author/{id}", async (int id, 
   IUseCase<GetAuthorByIdHandler, int, AuthorData> getAuthorbyId,
   IUseCase<GetBooksByAuthorHanler, GetBooksByAuthorQuery, IList<BookRecord>> getBookByAuthor,
   CancellationToken cancellationToken) =>
{
    AuthorData author = await getAuthorbyId.Invoke(id, CancellationToken.None);
    IList<BookRecord> books = await getBookByAuthor.Invoke(new GetBooksByAuthorQuery(id, 1, 25), cancellationToken);

    return new RazorComponentResult<TheStoryVault.Pages.Author.MainView>(new Dictionary<string, object?>()
    {
        {"Author", author },
        {"Books", books }
    });
});

Pomenovanie use-case sa mi paci viac ako terminologia mediatora (sedi to aj s UML-kom, aj s Clean architekturov, hoci ta pre mna neimplikuje vertical slices).
Zial kvoli typovej bezpenosti (genericke constrainty) musim v prvom pripade uvazat genericke typy pri volani Invoke alebo druhom pripade v interface.

Zatial som bez runtime reglexie a je to AOT frendly, lebo linker nevyhodi hamdleri z kodu vidim uplne presne, ktory use-case sa vola.

A bug v keyed services mi prekazil to mat pomenovane pipliny zadarmo.

No, myslel som s pomocou reflexie. Nieco ako



public static IEnumerable<Type> GetGenericInterfaceTypes(this Type type, Type genericTypeDefinition)
   => type.GetGenericInterfaces().Where(
        i => i.IsGenericType
             && i.GetGenericTypeDefinition() == genericTypeDefinition)

var handlers = assembly.GetTypes()
   .Where(t => t.IsClass && !t.IsAbstract)
   .SelectMany(t => t.GetGenericInterfaceTypes(typeof(ICommandHandler<>))
                     .Select(i => new 
                     {
                        ImplementationType = t, 
                        InterfaceType = i
                     }))
   .ToArray();


ICommandHandler<TCommand> CommandHandlerFactory<TImplementationType>(IServiceProvider serviceProvider) 
  where TImplementationType : ICommandHandler<TCommand>
{
  var handler = ActivatorUtilities.CreateInstance<TImplementationType>(sp);
  SetupLogging(handler, sp);
  return hander;
}

foreach (var handler in handlers)
{
  serviceCollection.Add(new ServiceDescriptor(
    serviceLifeTime: ServiceLife.Scoped
    serviceType: handler.InterfaceType, 
    factory: sp => this.GetType().GetMethod(nameof(CommandHandlerFactory))
                   method.MakeGenericMethod(handler.ImpemenationType)
                   .Invoke(sp);
  
}

Jasne chapem, dufal, som ze nejako pojde pouzit genericka registracia factory methody.


Skusim som si aj implementaciu s base triedov. Prva implementacia bola s injektovanim “infrastrukturneho rozhrania” ale to bolo skarede a otravne. Tak som to prerobil s injektovanim odboku cez DI factory.

No paradoxne mi pride, ze je tam viac “magie” ako v pripade rozhrania. Nazvoslovie stale nemam ucelene, lebo nechcem nazvat triedy slovesami a “Handler” sa tam tiez nehodi.

public class GetAuthorByIdUc : UseCaseBase<int, AuthorData>
{
    private readonly IAuthorService authorService;

    public GetAuthorByIdUc(IAuthorService authorService)
    {
        this.authorService = authorService;
    }

    protected override async ValueTask<AuthorData> InvokeCore(int request, CancellationToken cancellationToken)
    {
        return await ...
    }
}

A pouzitie. Na jednej strane je to kratsie, na druhej mi je trochu prosti srsti injektovat priamo triedu. Ale zas som sa zbavil otravnych generickych parametrov (to by sa dalo v pripade intterfacu obist source generatorom, ktory by generoval typove extension metody, ale ten sa mi nechce pisat).

V tomto pripade tiez pridem o moznost zmenit pipline pomocou keyed service.

app.MapGet("/author/{id}", async (int id,
    GetAuthorByIdUc getAuthorbyIdUc,
    GetAuthorBooksUc getAuthorBookUc,
    CancellationToken cancellationToken) =>
{
    AuthorData author = await getAuthorbyIdUc.Invoke(id, cancellationToken);
    IList<BookRecord> books = await getAuthorBookUc.Invoke(new GetAuthorBooksUcRequest(id, 0), CancellationToken.None);

    return new RazorComponentResult<TheStoryVault.Pages.Author.MainView>(new Dictionary<string, object?>()
    {
        {"Author", author },
        {"Books", books }
    });
});

Stale som na vazkach.

Tiez budem musiet premysliet aj dosledky zmenu nazvu, MediatR hovori, ze by so nemal volat jeden handler z druheho. Zatial co Use-Cases maju v UML definovany vztah include a extend a to by som chcel podporit.

Po niekolskych dalsich pokusoch z toho vyliezol projekt CaseR - GitHub - harrison314/CaseR: The main task of this library is to model use cases in an application and separate cross-cutting concerns. - je to sucasne aj moj prvy source generator, ktory ale pre funckost nie je potrebny - generuje len registraciu do DI kontainera a extension typove metody, aby nebolo potrebne pisat generika.

Nakoniec som po mnohych pokusoch skoncil s implementaciu pre Use-Case a snazil som sa tomu prisposobit hlavne terminologiu (IUseCase, UseCaseInteractor, Interceptor,…).

var todosApi = app.MapGroup("/todos");
            todosApi.MapGet("/", async (IUseCase<GetTodoInteractor> getTodoInteractor,
                CancellationToken cancellationToken) =>
            {
                WebAppExample.Todo.UseCases.Todo[] todos = await getTodoInteractor.Execute(new GetTodoInteractorRequest(), cancellationToken);
                return todos;
            });

A implementacia ineraktoru (interactor je pojem s clena architektury):

public record GetTodoInteractorRequest();

public record Todo(int Id, string? Title, DateOnly? DueBy = null, bool IsComplete = false);

public class GetTodoInteractor : IUseCaseInterceptor<GetTodoInteractorRequest, Todo[]>
{
    public GetTodoInteractor()
    {
        
    }

    public ValueTask<Todo[]> Execute(GetTodoInteractorRequest request, CancellationToken cancellationToken)
    {
        Todo[] sampleTodos = new Todo[] 
        {
                new Todo(1, "Walk the dog"),
                new Todo(2, "Do the dishes", DateOnly.FromDateTime(DateTime.Now)),
                new Todo(3, "Do the laundry", DateOnly.FromDateTime(DateTime.Now.AddDays(1))),
                new Todo(4, "Clean the bathroom"),
                new Todo(5, "Clean the car", DateOnly.FromDateTime(DateTime.Now.AddDays(2)))
        };

        return new ValueTask<Todo[]>(sampleTodos);
    }
}

Celkovo som este uvazovat ci volanie use case nazvat Invoke alebo Execute. Na odporucania kopilota som zvolil Execute, kedze tvrdil, ze Invoke je pre low-level operacie.

Clovek nieco riesi dva tyzdne a pritom taka blbost :smiley:

1 lajk

Zamyslam sa nad tou generickou registraciou do ServiceCollection.

Asi by to malo nejako ist, lebo ILogger<TCategory> je podla mna presne ten isty pripad, tak mozno skus pozriet ServiceCollection.AddLogging()

Zial za tym nie je ziadna magia, len klasicka genricka registracia