Skip to content

How Do I...

This document details how to implement CQRS with the Mediator pattern and how to build and extend automatically instantiated strategies for your feature.

Overview

Visual Overview

mermaid
flowchart TB
    P["Program.cs"] --> E

    subgraph FeatureSlice[Features/MyFeature Vertical Slice]
        E[Feature Endpoints]
        C[Commands + Handlers]
        Q[Queries + Handlers]
        N["Notifications + Handlers (optional)"]
        D["Domain Services<br>(Strategies + Extra Logic)"]
    end

    E --> |Mediator|S[ISender]
    E --> |Mediator|PUB[IPublisher]

    S --> DISP[Dispatcher]
    DISP --> PB[Pipeline Behaviors]
    PB --> C
    PB --> Q

    PUB --> N
    C --> D
    Q --> D

    SC[ServiceCollectionExtensions] --> REG[Register services]
    REG --> D

INFO

Pipeline Behaviours provide cross-cutting concerns for the project. They are part of the DI and are executed around your feature. They include logging, validation, opt-in caching, opt-in transactions and will be extended to (for example) retries, metrics, auditing.

Suggested Folder Structure

This is the expected folder structure, under Imparta.iCoachAi.Api. Obviously, give your feature a nice sensible name.

You can start smaller and add folders and classes only when needed.

mermaid
---
config:
    treeView:
        rowIndent: 20
        lineThickness: 1
    themeVariables:
        treeView:
            labelFontSize: '16px'
            labelColor: '#999'
            lineColor: '#999'
---
treeView-beta
  "Features"
    "Lemons"
      "Commands"
        "CreateCommand.cs"
        "DeleteCommand.cs"
        "UpdateCommand.cs"
      "Queries"
        "GetByIdQuery.cs"
        "ListQuery.cs"
      "Notifications"
        "FeatureDeletedNotification.cs"
        "NotificationHandler.cs"
      "Domain"
        "ILemonStrategy.cs"
        "TooManyLemonsStrategy.cs"
        "GreenLemonsStrategy.cs"
        "LemonProvider.cs"
      "LemonsResponse.cs"
      "LemonsEndpoints.cs"

The Commands / Queries / Notifications derive from a "best-practice" CQRS pattern. They intentionally have basic names, as they will be very similar across all features. Change it if needed, as long as it's obvious what it's intention is.

The Domain and strategies will follow the Strategy Pattern.

Approaches

There are 2 goals when adding new functionality and features:

  1. Writing CRUD via CQRS with a Mediator
    1. Simple GETs and POSTs (etc) for getting and creating items from a data source, for display on the UI.
  2. Building an executable strategy to apply specific functionality when requested.
    1. This replaces the existing tools configuration files (that look like JSON, but are Typescript) that define (for example) Modules and Agent Tools.
    2. You must still provide the strategy endpoints and call Execute().

Depending on your feature / requirements, you may need both.

CQRS with a Mediator

CQRS requires you to:

  • Add each API endpoint (LemonEndpoints.cs).
  • Add feature files (commands, queries, handlers, optional notifications and any additional domain functionality).
    • A different folder structure may be required to prevent mix-up with the Strategy pattern.
    • Subscribe to optional cacheing, transactions, etc
  • Add required IServiceCollection registration.
  • Add tests...

Add Endpoints for Lemon Reqeusts

First, add your endpoints into LemonEndpoints.cs.

Create the static class:

  • public static class LemonEndpoints(this WebApplication app) {}

Create the endpoints:

cs
apiGroup.MapGet("/", Get);
apiGroup.MapGet("/{id:guid}", GetById);
apiGroup.MapPost("/", Create);

TIP

Ensure the endpoints are RESTful APIs

Each endpoint method should have the following as method signature:

  • Receive the data, from the path and/or as a query or command record (see below)
    • Not required if, eg, getting all.
  • Inject an ISender (from the mediator) to send your request.
  • Optionally inject an IPublisher to create a notification.
  • Use a CancellationToken.
cs
private static async Task<IResult> Get(ISender sender, CancellationToken cancellationToken) {}

private static async Task<IResult> UpdateById(
    Guid id,
    UpdateCommand command,
    ISender sender,
    IPublisher publisher,
    CancellationToken cancellationToken
) {}

TIP

As a static class, it cannot have instance constructors. Therefore, interfaces are injected by method.

Each function is then required to:

  • Send data to the Handler
    • await sender.Send(new ListQuery(), cancellationToken)
    • await sender.Send(command, cancellationToken);
Example: Define each of the required endpoints, LemonEndpoints.cs
csharp
namespace Imparta.iCoachAi.Api.Features.Lemons;

using Imparta.iCoachAi.Api.Common.Attributes;
using Imparta.iCoachAi.Api.Common.Dispatcher;
using Imparta.iCoachAi.Api.Features.Lemons.Commands;
using Imparta.iCoachAi.Api.Features.Lemons.Domain;
using Imparta.iCoachAi.Api.Features.Lemons.Notifications;
using Imparta.iCoachAi.Api.Features.Lemons.Queries;

public static class ModuleEndpoints
{
    public static void MapLemonEndpoints(this WebApplication app)
    {
        var apiGroup = app.MapGroup("/api/lemons").WithTags("Lemons");
        // TODO: we need to add API versioning!

        apiGroup.MapGet("/", Get);

        apiGroup.MapGet("/{id:guid}", GetById);

        apiGroup.MapPost("/", Create);

        apiGroup.MapPut("/{id:guid}", UpdateById);

        apiGroup.MapDelete("/{id:guid}", DeleteById);
    }

    private static async Task<IResult> Get(ISender sender, CancellationToken cancellationToken)
    {
        var modules = await sender.Send(new ListQuery(), cancellationToken);

        return modules is not null ? TypedResults.Ok(modules) : TypedResults.NoContent();
    }

    private static async Task<IResult> Create(
        CreateCommand command,
        ISender sender,
        IPublisher publisher,
        CancellationToken cancellationToken
    )
    {
        var id = await sender.Send(command, cancellationToken);

        await publisher.Publish(new LemonCreatedNotification(id, command.Name), cancellationToken);

        return Results.Created($"/modules/{id}", new { id });
    }

    [Timer]
    private static async Task<IResult> UpdateById(
        Guid id,
        UpdateCommand command,
        ISender sender,
        CancellationToken cancellationToken
    )
    {
        if (id != command.Id)
        {
            return TypedResults.BadRequest("Update id and the data body id do not match.");
        }

        var done = await sender.Send(command, cancellationToken);

        return done ? TypedResults.NoContent() : TypedResults.NotFound();
    }

    ......
}

TIP

Be sure to use the features of Minimal APIs.

TIP

There are also optional attributes to subscribe to additional functionality. UpdateById() uses the Timer attrubute, which will time the duration of the entire request and add it to the response headers (x-requesttime-ms). That functionality is written with middleware (/Middleware/RequestDurationMiddleware.cs).

WARNING

The MapLemonEndpoints(this WebApplication app) must be static, and also must be called from Program.cs (app.MapLemonEndpoints();)[1]

Add Queries and Commands + Handlers

The Queries and the Commands are just simple record types, defining the incomming data object (request). The mediator works by discovering the right handler, based on its received request type.

The records are sealed and implement IRequest<>. They can additionally subscribe to other behaviours.

  • public sealed record ListQuery : IRequest<IReadOnlyList<TResponse>>;
  • public sealed record UpdateCommand(Guid Id, string Name) : IRequest<bool>, ITransactional;

IRequest<> takes an output (a response). Here, record ListQuery expects to return a IReadOnlyList<LemonsResponse> when requested.

The Handler class is again sealed and implements IRequestHandler<>

IRequestHandler<> takes an input and an output object.

"Given I have one of these, I want one of those...".

It should match the appropriate record type.

  • public sealed class ListQueryHandler() : IRequestHandler<ListQuery, IReadOnlyList<TResponse>>

To implement the interface, add the Handle() method. Write all necessary code in here (or via additional Domain logic) to return the response.

cs
public async ValueTask<IReadOnlyList<ModuleDto>> Handle(
    ListQuery request,
    CancellationToken cancellationToken
)

INFO

The mediator returns a ValueTask<TResponse> instead of a Task<TResponse>. ValueTask<> avoids the heap allocation when a handler completes synchronously, which is a measurable win for cached queries and trivial commands. In return, callers cannot await a ValueTask more than once (should not be a problem in practice).

Example: List Query + Handler (GET), Queries/ListQuery.cs
csharp
namespace Imparta.iCoachAi.Api.Features.Lemons.Queries;

using Imparta.iCoachAi.Api.Common.Dispatcher;

public sealed record ListQuery : IRequest<IReadOnlyList<ModuleDto>>, ICacheable;

public sealed class ListQueryHandler() : IRequestHandler<ListQuery, IReadOnlyList<ModuleDto>>
{
    public async ValueTask<IReadOnlyList<ModuleDto>> Handle(
        ListQuery request,
        CancellationToken cancellationToken
    )
    {
        // TODO: Get data from the data store.
        var lemons = GetAllLemons();
        return Task.FromResult(lemons).Result;
    }
}

The Commands (POSTs, PUTs, DELETEs) are very similar to Queries. Create a sealed record CreateCommand and implement IRequest<TResponse>, create the sealed class CreateCommandHandler() : IRequestHandler<TRequest, TResponse> class, implement Handle() : IRequestHandler<TRequest, TResponse>() {}.

Additionally for Commands, validation is expected on the input values. This is easily achieved using FluentValidation. Create the sealed class, inheriting from AbstractValidator<>:

  • public sealed class CreateCommandValidator : AbstractValidator<TRequest>

Add rules into the constructor.

cs
public CreateCommandValidator()
{
    RuleFor(x => x.Name).NotEmpty().MinimumLength(5).MaximumLength(20);
}

Full documentation for the rules is available from the FluentValidation site.

Example: Update Command + Handler (POST) with validation, Commands/CreateCommand.cs
csharp
namespace Imparta.iCoachAi.Api.Features.Lemons.Commands;

using FluentValidation;

using Imparta.iCoachAi.Api.Common.Dispatcher;
using Imparta.iCoachAi.Api.Common.Interfaces;

public sealed record CreateCommand(string Name) : IRequest<Guid>, ITransactional;

public sealed class CreateCommandValidator : AbstractValidator<CreateCommand>
{
    public CreateCommandValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MinimumLength(5).MaximumLength(20);
    }
}

public sealed class CreateCommandHandler() : IRequestHandler<CreateCommand, Guid>
{
    public async ValueTask<Guid> Handle(
        CreateCommand request,
        CancellationToken cancellationToken
    )
    {
        var lemon = new Lemon
        {
            Id = Guid.NewGuid(),
            Name = request.Name,
        };

        // insert new lemon...

        return Task.FromResult(lemon.Id).Result;
    }
}

Because the project already registers validators globally, through Behaviours on the Request Processing Pipeline, this validator is automatically applied by ValidationBehaviour.

This class also implements ITransactional. This is an empty interface, which will provide database transaction behaviour.

Behaviours

Queries and Commands can also subscribe to any of the optional Behaviours. Append the interfaces as required:

  • public sealed class ListQueryHandler() : IRequestHandler<ListQuery, IReadOnlyList<TResponse>>, ICacheable
  • public sealed record CreateCommand(string Name) : IRequest<Guid>, ITransactional;

Adding a new behaviour is currently out of scope for this document. There are examples in Common/Behaviours folder, and below.

WARNING

The order of behaviours is important. They are executed outside-in <do work> then inside-out - that is why they must be added manually to IServiceCollection: services.AddPipelineBehavior(typeof(LoggingBehaviour<,>));

There are currently 4 behaviour classes:

  • LoggingBehaviour (always on)
  • ValidationBehaviour (always on)
  • CachingBehaviour (opt-in, ICacheable)
  • TransactionBehaviour (opt-in, ITransactional)

This count is expected to extend, to include (for example) retries, failure rates, metrics, idempotency, latency logging, ...

Optional: Add Notification(s) + Handler

Notifications are additional functionality to allow the "publish" of an event. Examples include additional logging, (email, teams) notifications, data store updates, etc.

They run asynchronously and consist of the notification object with the properties required by the notification, Notifications/LemonCreatedNotification.cs...

  • public record LemonCreatedNotification(Guid Id, string Name) : INotification;

...and a custom handler*, Notifications/AuditLogHandler.cs

cs
public class AuditLogHandler(ILogger<AuditLogHandler> logger)
    : INotificationHandler<LemonCreatedNotification>

They implement INotification and INotificationHandler<TNotification> from the Common.Dispatcher namespace (mediator).

Finally, implement the interface,

cs
public ValueTask Handle(
    LemonCreatedNotification notification,
    CancellationToken cancellationToken
) {}
Example: LemonCreatedNotification + Handler Notifications/AuditLogHandler.cs
cs
namespace Imparta.iCoachAi.Api.Features.Modules.Notifications;

using Imparta.iCoachAi.Api.Common.Dispatcher;

public record LemonCreatedNotification(Guid Id, string Name) : INotification;
cs
namespace Imparta.iCoachAi.Api.Features.Lemons.Notifications;

using Imparta.iCoachAi.Api.Common.Dispatcher;

public class AuditLogHandler(ILogger<AuditLogHandler> logger)
    : INotificationHandler<LemonCreatedNotification>
{
    public ValueTask Handle(
        LemonCreatedNotification notification,
        CancellationToken cancellationToken
    )
    {
        logger.LogCritical(
            "Notification for: Created a new lemon with id: {Id} and name: {Name}.  Writing audit log...",
            notification.Id,
            notification.Name
        );
        return ValueTask.CompletedTask;
    }
}

Handle() should return a completed async ValueTask:

  • return ValueTask.CompletedTask;

TIP

The notification can be handled in any way, as required.

Here, a (critical!) audit log is created when the LemonCreatedNotification notification is published.

Any number of notifications can be handled by AuditLogHandler, just implement Handle for each of the listed INotificationHandler<TNotification>. Of course, LemonCreatedNotification can be reused any number of times.

These notifications will be published by await publisher.Publish(new LemonCreatedNotification(id, command.Name), cancellationToken); in the relevent endpoint method.

* These handlers may become fairly generic, but each INotification must be handled by a INotification.Handle().

Strategy Pattern Optional

Steps for a strategy, using the Strategy Pattern:

  • Add each strategy API endpoint (LemonEndpoints).
  • Implement a strategy interface (ILemonStrategy) for each concrete strategy.*
  • Create an optional strategy provider class, LemonStrategy to inherit the StrategyProvider abstract class.
  • Add classes and the strategy provider to IServiceCollection registration.

* See below for other options.

Add Endpoints for Strategies

This section assumes familarity with preceding information.

Create the strategy endpoints in the static LemonEndpoints(this WebApplication app) class.

Example: Add Endpoints for Strategies, LemonEndpoints.cs
cs
public static void MapModuleEndpoints(this WebApplication app)
{
    var moduleGroup = app.MapGroup("/api/modules").WithTags("Modules");
    //var apiVersion1 = app.NewApiVersionSet()
    //    .HasApiVersion(new Asp.Versioning.ApiVersion(1))
    //    .ReportApiVersions()
    //    .Build();

    apiGroup.MapGet("/strategies", GetAvailableStrategies).WithTags("Module Strategies");
    //.WithApiVersionSet(apiVersion1);

    apiGroup.MapPost("/strategies/{name}", ExecuteStrategy).WithTags("Module Strategies");
    //.WithApiVersionSet(apiVersion1);
}

 private static async Task<IResult> GetAvailableStrategies(LemonProvider lemonProvider)
 {
     return TypedResults.Ok(moduleProvider.AvailableStrategies);
 }

 [Timer]
 private static IResult ExecuteStrategy(string name, LemonProvider lemonProvider)
 {
     try
     {
         var lemon = lemonProvider.Create(name);
         lemon.Execute();

         return TypedResults.Ok(new { Strategy = name, Message = "Executed." });
     }
     catch (KeyNotFoundException ex)
     {
         return TypedResults.NotFound(new { ex.Message });
     }
     catch (ArgumentException ex)
     {
         return TypedResults.BadRequest(new { ex.Message });
     }
 }

INFO

Sadly, API versioning is not yet set up - but it should be very, very soon...

When handling the endpoint(s), the key is to create a strategy class instance from the provider (see below).

  • var lemon = lemonProvider.Create(name);

Where name is the name of the strategy from the request path and lemonProvider is the strategy provider, injected into the method signature.

TIP

Ensure that the injected provider is the same one that is added to IServiceCollection.

This is either a custom provider (LemonProvider), or the generic provider (StrategyProvider).

Then, call Execute(), as defined by the provider's interface.

  • lemon.Execute()

WARNING

The strategy name in the path parameter must match the class name.

This is due to the way reflection is discovering the strategies.

A list of available strategies is also available.

  • return TypedResults.Ok(moduleProvider.AvailableStrategies);

INFO

Note the safe catching and handling of both KeyNotFoundException and ArgumentException, which are thrown by this strategy provider.

This ensures appropriate results are returned via the response to be gracefully handled by the caller[2].

Strategy Provider

A strategy provider is required to provide a list of the available strategies and to create a strategy class instance when requested.

  • A dictionary (IEnumerable<string>) of strategies, AvailableStrategies.
  • An instance of the strategy, Create(string strategyName).

There is a generic StrategyProvider, which provides all of the reflection and class instantiation that is required. It should be reused where possible.

Example: Strategy Interface IExecuteStrategy
cs
namespace Imparta.iCoachAi.Api.Features;

public interface IExecuteStrategy
{
    void Execute();
}
Example: Strategy Provider, StrategyProvider.cs

The detail of the provider is in an abstract class.

The concrete StrategyProvider inherits from that class, and has no need to override any of the methods.

StrategyProvider also provides the ability to use a custom strategy interface or the generic one (IExecuteStrategy).

cs
namespace Imparta.iCoachAi.Api.Features;

using System.Collections.Frozen;

public abstract class StrategyProviderAbstract<TInterface>(IServiceProvider serviceProvider) where TInterface : class
{
    private static readonly IReadOnlyDictionary<string, Type> StrategyType = typeof(TInterface)
        .Assembly.GetTypes()
        .Where(type =>
            type is { IsClass: true, IsAbstract: false } && typeof(TInterface).IsAssignableFrom(type)
        )
        .ToDictionary(type => type.Name, type => type, StringComparer.OrdinalIgnoreCase);

    private static readonly FrozenDictionary<string, Type> FrozenStrategyTypes =
        StrategyType.ToFrozenDictionary();

    public virtual IEnumerable<string> AvailableStrategies =>
        StrategyType.Keys.Order(StringComparer.OrdinalIgnoreCase);

    public virtual TInterface Create(string strategyName)
    {
        if (string.IsNullOrWhiteSpace(strategyName))
        {
            throw new ArgumentException("Strategy name is required.", nameof(strategyName));
        }

        var strategyType =
            FrozenStrategyTypes
                .FirstOrDefault(strategy => strategy.Key.Equals(strategyName, StringComparison.OrdinalIgnoreCase))
                .Value
            ?? throw new KeyNotFoundException($"Strategy '{strategyName}' was not found.");

        return (TInterface)ActivatorUtilities.CreateInstance(serviceProvider, strategyType);
    }
}
cs
namespace Imparta.iCoachAi.Api.Features
{
    public class StrategyProvider<TInterface>(IServiceProvider serviceProvider)
        : StrategyProviderAbstract<TInterface>(serviceProvider)
        where TInterface : class
    { }

    public class StrategyProvider(IServiceProvider serviceProvider)
        : StrategyProviderAbstract<IExecuteStrategy>(serviceProvider)
    { }
}

There are a few ways to create a strategy provider:

  1. Reuse the existing generic concrete provider, StrategyProvider using the existing IExecuteStrategy interface.
    • Each strategy should implement IExecuteStrategy, and provide a parameterless Execute() method[3].
      • Both are in the Imparta.iCoachAi.Api.Features namespace.
      • There is no need to add this to IServiceCollection, as it's already available.
    • This is the preferred, and most simple, option.
  1. Reuse the existing generic provider, but with a custom interface (ILemonStrategy).

    • For example, if Execute() has different parameters.

    • Strategies must be added to IServiceCollection. The provider is already added as a Singleton.

      • With a scan for ILemonStrategy concrete implementations.
      cs
        services.Scan(scan =>
            scan.FromAssemblyOf<ILemonStrategy>()
                .AddClasses(classes => classes.AssignableTo<ILemonStrategy>())
                .AsImplementedInterfaces()
                .WithScopedLifetime()
        );

      INFO

      The Scan extension is from the Scrutor library.

      • Alternatively, use the IServiceCollection extension method. All ILemonStrategy classes will be registered automatically.
        • services.AddStrategyClasses<ILemonStrategy>();

      INFO

      See below for more details about services.AddStrategyClasses.

      It is an IServiceCollection extension method to scan and register strategy classes and the strategy provider.

    TIP

    Ensure that the strategy endpoints are injecting the correct provider.

    Using AddStrategyClasses<ILemonStrategy>() requires StrategyProvider.

    • private static IResult ExecuteStrategy(string name, StrategyProvider lemonProvider)
  2. Create a custom LemonProvider to implement the StrategyProviderAbstract with a custom strategy interface (ILemonStrategy). StrategyProviderAbstract is an abstract class. It can be:

    • Implemented via an empty class and passing ILemonStrategy:
    cs
    public sealed class LemonProvider(IServiceProvider serviceProvider) : StrategyProviderAbstract<ILemonStrategy>(serviceProvider)
    {
    }
    • Implemented via a class overriding the virtual methods:
      • virtual IEnumerable<string> AvailableStrategies
      • virtual ILemonStrategy Create(string strategyName).
    • Each option must be added to IServiceCollection.
      • The provider as a Singleton, services.AddSingleton<LemonProvider>();
      • The strategies with a scan for ILemonStrategy concrete implementations.
      • Preferably, use the IServiceCollection extension to wrap both steps.
        • services.AddStrategyClasses<ILemonStrategy, LemonProvider>();
    • This may be useful for naming clarity, or perhaps to add additional functionality (eg, logging). Remember that the base virtaul method can still be reused with base.Create(strategyName);
  3. Roll your own and ensure it's all added to IServiceCollection...

    • Suggestions:
      • Strategies implementing a singe interface must be discoverable when the application starts.
      • A FrozenDictionary is preferred to store the strategies (by name, or other).
      • Creating a class instance should not itself use any reflection at runtime.

AddStrategyClasses : IServiceCollection Extension method

There are two IServiceCollection extension methods to support strategy registration. They are available from the Imparta.iCoachAi.Api.Extensions namespace.

Usage

  • When reusing existing interface and provider, there is no need to create or register anything.
  • When only creating a custom interface, use the AddStrategyClasses extension passing only the custom interface. This will scan for all concrete strategies implementing that interface:
    • services.AddStrategyClasses<ILemonStrategy>.
cs
using Imparta.iCoachAi.Api.Extensions;

public static class ServiceCollectionExtensions
{
    public static void AddFeatureServices(this IServiceCollection services)
    {
        services.AddStrategyClasses<ILemonStrategy>();
    }
}
....
  • When creating both a custom interface and a custom provider, use the AddStrategyClasses extension passing both the custom interface and the custom provider. This will scan for all concrete strategies implementing that interface and register the provider as a singleton.
    • services.AddStrategyClasses<ILemonStrategy, LemonProvider>();
cs
using Imparta.iCoachAi.Api.Extensions;

public static class ServiceCollectionExtensions
{
    public static void AddFeatureServices(this IServiceCollection services)
    {
        services.AddStrategyClasses<ILemonStrategy, LemonProvider>();
    }
}
Example: AddStrategyClasses() extension methods
cs

 public static void AddStrategyClasses<TInterface, TProvider>(this IServiceCollection services)
     where TInterface : class
     where TProvider : class
 {
     services.Scan(scan =>
         scan.FromAssemblyOf<TInterface>()
             .AddClasses(classes => classes.AssignableTo<TInterface>())
             .AsImplementedInterfaces()
             .WithScopedLifetime()
     );

     services.AddSingleton<TProvider>();
 }

 public static void AddStrategyClasses<TInterface>(this IServiceCollection services) where TInterface : class
 {
    services.Scan(scan =>
        scan.FromAssemblyOf<TInterface>()
            .AddClasses(classes => classes.AssignableTo<TInterface>())
            .AsImplementedInterfaces()
            .WithScopedLifetime()
    );
 }

A Note on IServiceCollection Registration

At the moment, all dependency registrations lives in one Extensions\ServiceCollectionExtensions.cs class.

It would be better for each Feature to have its own extension class, and for the current one to call one line, for example, services.AddLemonFeature().

WIP...

Checklist

  • Create Features/Lemons folder and core files.
  • Add Lemons CRUD endpoints with a route group.
  • Add at least one query and/or one command handler.
  • Add validator(s) for commands.
  • Add optional notification and handler.
  • Add required IServiceCollection registrations for Lemons-specific services.
  • Map Lemons endpoints to IServiceCollection.
    • This probably will move to the more local Service extensions.
  • Keep Lemons feature as a vertical slice: endpoint + handlers + related contracts, optional strategies and domain behavior together.

Appendix

RESTful APIs

A RESTful API exposes resources over HTTP using predictable URL patterns and standard HTTP verbs.

GET reads data. POST creates or triggers work. PUT updates data. DELETE removes data.

In this project, endpoints such as /api/lemons/strategies and /api/lemons are resource-oriented API surfaces. Each route returns standard HTTP status codes (200, 201, 204, 400, 404) so clients can reliably understand outcomes.

All Nouns are plural.

TIP

Sometimes API endpoint registration order is important.

For example, path /api/lemons/strategies is the same as /api/lemons/{name}, but the delegate handler will be different - leading to unexpected behaviour.

text
// list all lemons
GET https://api.maia.com/lemons

// get a specified lemon
GET https://api.maia.com/modules/6

// create a new module
POST https://api.maia.com/lemons
  { "title": "I love lemons", "description": "and I Love Maia" }

GET https://api.maia.com/lemons/strategy/strategy-42

// this is a POST due to it having side-effects, there is not body
POST https://api.maia.com/lemons/strategy

Example Behaviour: Logging

Example: Behaviour, LoggingBehaviour.cs
cs
namespace Imparta.iCoachAi.Api.Common.Behaviours;

using System.Diagnostics;

using Imparta.iCoachAi.Api.Common.Dispatcher;

public sealed class LoggingBehaviour<TRequest, TResponse>(ILogger<LoggingBehaviour<TRequest, TResponse>> logger)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    public async ValueTask<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var requestName = typeof(TRequest).Name;
        logger.LogInformation("Handling {RequestName}", requestName);

        var sw = Stopwatch.StartNew();
        try
        {
            var response = await next();
            sw.Stop();
            logger.LogInformation("Handled {RequestName} in {Elapsed}ms", requestName, sw.ElapsedMilliseconds);
            return response;
        }
        catch (Exception ex)
        {
            sw.Stop();
            logger.LogError(ex, "Handler {RequestName} threw after {Elapsed}ms", requestName, sw.ElapsedMilliseconds);
            throw;
        }
    }
}

  1. Moving forward, this should be via (a new) extension class ↩︎

  2. Moving forward, all non-handled exceptions should be caught via middleware and logged as unhandled before retruning a graceful response. ↩︎

  3. The mention of parameterless Execute() method will change when parameters are passed in. This will likely use a custom array of objects for casting by the strategy classes. ↩︎