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
- The project implements the CQRS pattern with a mediator.
- The mediator is in-house, and is currently in the
Common\Dispatcherfolder. - The project implements the Strategy Pattern.
- Vertical slices are used within the
Featuresfolder. - Your feature is called
Lemons.
Visual Overview
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 --> DINFO
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.
---
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:
- Writing CRUD via CQRS with a Mediator
- Simple GETs and POSTs (etc) for getting and creating items from a data source, for display on the UI.
- Building an executable strategy to apply specific functionality when requested.
- This replaces the existing tools configuration files (that look like JSON, but are Typescript) that define (for example) Modules and Agent Tools.
- 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
IServiceCollectionregistration. - 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:
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
IPublisherto create a notification. - Use a
CancellationToken.
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
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.
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
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.
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
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>>, ICacheablepublic 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
public class AuditLogHandler(ILogger<AuditLogHandler> logger)
: INotificationHandler<LemonCreatedNotification>They implement INotification and INotificationHandler<TNotification> from the Common.Dispatcher namespace (mediator).
Finally, implement the interface,
public ValueTask Handle(
LemonCreatedNotification notification,
CancellationToken cancellationToken
) {}Example: LemonCreatedNotification + Handler Notifications/AuditLogHandler.cs
namespace Imparta.iCoachAi.Api.Features.Modules.Notifications;
using Imparta.iCoachAi.Api.Common.Dispatcher;
public record LemonCreatedNotification(Guid Id, string Name) : INotification;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,
LemonStrategyto inherit theStrategyProviderabstract class. - Add classes and the strategy provider to
IServiceCollectionregistration.
* 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
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
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).
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);
}
}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:
- Reuse the existing generic concrete provider,
StrategyProviderusing the existingIExecuteStrategyinterface.- Each strategy should implement
IExecuteStrategy, and provide a parameterlessExecute()method[3].- Both are in the
Imparta.iCoachAi.Api.Featuresnamespace. - There is no need to add this to
IServiceCollection, as it's already available.
- Both are in the
- This is the preferred, and most simple, option.
- Each strategy should implement
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 aSingleton.- With a scan for
ILemonStrategyconcrete implementations.
csservices.Scan(scan => scan.FromAssemblyOf<ILemonStrategy>() .AddClasses(classes => classes.AssignableTo<ILemonStrategy>()) .AsImplementedInterfaces() .WithScopedLifetime() );INFO
The
Scanextension is from theScrutorlibrary.- Alternatively, use the
IServiceCollectionextension method. AllILemonStrategyclasses will be registered automatically.services.AddStrategyClasses<ILemonStrategy>();
INFO
See below for more details about
services.AddStrategyClasses.It is an
IServiceCollectionextension method to scan and register strategy classes and the strategy provider.- With a scan for
TIP
Ensure that the strategy endpoints are injecting the correct provider.
Using
AddStrategyClasses<ILemonStrategy>()requiresStrategyProvider.private static IResult ExecuteStrategy(string name, StrategyProvider lemonProvider)
Create a custom
LemonProviderto implement theStrategyProviderAbstractwith a custom strategy interface (ILemonStrategy).StrategyProviderAbstractis anabstractclass. It can be:- Implemented via an empty class and passing
ILemonStrategy:
cspublic sealed class LemonProvider(IServiceProvider serviceProvider) : StrategyProviderAbstract<ILemonStrategy>(serviceProvider) { }- Implemented via a class overriding the
virtualmethods:virtual IEnumerable<string> AvailableStrategiesvirtual 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
ILemonStrategyconcrete implementations. - Preferably, use the
IServiceCollectionextension to wrap both steps.services.AddStrategyClasses<ILemonStrategy, LemonProvider>();
- The provider as a
- This may be useful for naming clarity, or perhaps to add additional functionality (eg, logging). Remember that the base
virtaulmethod can still be reused withbase.Create(strategyName);
- Implemented via an empty class and passing
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
FrozenDictionaryis preferred to store the strategies (by name, or other). - Creating a class instance should not itself use any reflection at runtime.
- Suggestions:
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
AddStrategyClassesextension passing only the custom interface. This will scan for all concrete strategies implementing that interface:services.AddStrategyClasses<ILemonStrategy>.
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
AddStrategyClassesextension 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>();
using Imparta.iCoachAi.Api.Extensions;
public static class ServiceCollectionExtensions
{
public static void AddFeatureServices(this IServiceCollection services)
{
services.AddStrategyClasses<ILemonStrategy, LemonProvider>();
}
}Example: AddStrategyClasses() extension methods
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/Lemonsfolder 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
IServiceCollectionregistrations 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.
// 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/strategyExample Behaviour: Logging
Example: Behaviour, LoggingBehaviour.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;
}
}
}Moving forward, this should be via (a new) extension class ↩︎
Moving forward, all non-handled exceptions should be caught via middleware and logged as unhandled before retruning a graceful response. ↩︎
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. ↩︎