LoomKit.Requests
10.0.2
dotnet add package LoomKit.Requests --version 10.0.2
NuGet\Install-Package LoomKit.Requests -Version 10.0.2
<PackageReference Include="LoomKit.Requests" Version="10.0.2" />
<PackageVersion Include="LoomKit.Requests" Version="10.0.2" />
<PackageReference Include="LoomKit.Requests" />
paket add LoomKit.Requests --version 10.0.2
#r "nuget: LoomKit.Requests, 10.0.2"
#:package LoomKit.Requests@10.0.2
#addin nuget:?package=LoomKit.Requests&version=10.0.2
#tool nuget:?package=LoomKit.Requests&version=10.0.2
LoomKit.Requests
A lightweight mediator library for .NET: send requests (with or without a response) to handlers resolved through dependency injection, with an optional per-request-type middleware pipeline. If you've used MediatR, the shape will feel familiar.
Status: early stage. The public API may still change between versions — pin a commit/tag if you depend on it.
Features
- Fire-and-forget requests (
IRequest) and request/response requests (IRequest<TResponse>) - Handlers resolved from your DI container (
Microsoft.Extensions.DependencyInjection) - An optional middleware pipeline per request type, configured at startup
- Built-in tracing via
System.Diagnostics.ActivitySource(OpenTelemetry-compatible) CancellationTokenpropagated end-to-end, fromSendAsyncthrough every middleware down to the handler- Extensible: bring your own
IRequestSenderimplementation if the default one doesn't fit
Requirements
- .NET 10 or later
- Depends on
LoomKit.Requests.Abstractions(the interfaces and abstract base types, in their own package) andMicrosoft.Extensions.DependencyInjection.Abstractions— no other runtime dependency
Architecture: split from LoomKit.Requests.Abstractions
The interfaces (IRequest, IRequestHandler<>, IRequestSender) and abstract base types (RequestMiddleware<>, RequestSender<>, RequestSenderOptions, RequestSenderOptionsBuilder<>) live in the separate, lighter LoomKit.Requests.Abstractions package, which this package references. LoomKit.Requests adds the concrete pieces on top: DefaultRequestSender, the DI registration helpers, assembly-scanning handler discovery, and tracing.
This means a project that only needs to define requests/handlers — typically a domain/DDD class library that shouldn't know how requests get dispatched — can depend on LoomKit.Requests.Abstractions alone, keeping the concrete mediator implementation confined to your application/composition-root layer:
dotnet add package LoomKit.Requests.Abstractions # domain layer: define IRequest/IRequestHandler
dotnet add package LoomKit.Requests # application layer: wire up the sender
Installation
Via NuGet (recommended)
dotnet add package LoomKit.Requests
Available on nuget.org — a package version is published automatically for every vX.Y.Z tag pushed to this repo.
If you'd rather build against the source directly instead (e.g. to track main, or to debug/modify the library alongside your app), two options:
As a git submodule
git submodule add https://github.com/andreafoorlan/LoomKit.Requests.git external/LoomKit.Requests
cd external/LoomKit.Requests
git checkout v1.0.0
cd ../..
git add external/LoomKit.Requests
git commit -m "Add LoomKit.Requests submodule pinned to v1.0.0"
Then reference the project from your solution/project:
<ProjectReference Include="..\external\LoomKit.Requests\src\LoomKit.Requests.csproj" />
When cloning a repository that already has this submodule:
git clone --recurse-submodules <your-repo-url>
# or, on an existing clone:
git submodule update --init --recursive
To move to a newer release later:
cd external/LoomKit.Requests
git fetch --tags
git checkout v1.1.0
cd ../..
git add external/LoomKit.Requests
git commit -m "Bump LoomKit.Requests submodule to v1.1.0"
(git submodule add -b <tag> doesn't pin reliably since submodules track branches, not tags — checkout inside the submodule plus committing the resulting gitlink in the parent repo is what actually pins the commit.)
Plain project reference
If you're vendoring the source directly instead of using a submodule:
<ProjectReference Include="..\path\to\LoomKit.Requests\src\LoomKit.Requests.csproj" />
Core concepts
| Type | Purpose |
|---|---|
IRequest |
Marker interface for a fire-and-forget request (no response). |
IRequest<TResponse> |
Marker interface for a request that returns a TResponse. |
IRequestHandler<TRequest> / IRequestHandler<TRequest, TResponse> |
Implement one per request type — this is where the actual logic lives. |
IRequestSender |
The entry point your application code calls: SendAsync(...). |
RequestMiddleware<TRequest> / RequestMiddleware<TRequest, TResponse> |
Optional cross-cutting behavior wrapped around a handler (logging, validation, retries, ...). |
Quick start
1. Define requests and handlers
// Fire-and-forget
public sealed class PingCommand : IRequest;
public sealed class PingCommandHandler : IRequestHandler<PingCommand>
{
public Task HandleAsync(PingCommand request, CancellationToken cancellationToken = default)
{
Console.WriteLine("Pong");
return Task.CompletedTask;
}
}
// Request/response
public sealed class GetUserQuery : IRequest<UserDto>
{
public required Guid UserId { get; init; }
}
public sealed class GetUserQueryHandler : IRequestHandler<GetUserQuery, UserDto>
{
private readonly IUserRepository _repository;
public GetUserQueryHandler(IUserRepository repository) => _repository = repository;
public async Task<UserDto> HandleAsync(GetUserQuery request, CancellationToken cancellationToken = default)
{
var user = await _repository.GetAsync(request.UserId, cancellationToken);
return new UserDto(user.Id, user.Name);
}
}
2. Register handlers and the sender in DI
Handlers are plain DI services. Register them one by one:
services.AddScoped<IRequestHandler<PingCommand>, PingCommandHandler>();
services.AddScoped<IRequestHandler<GetUserQuery, UserDto>, GetUserQueryHandler>();
services.AddDefaultRequestSender(options => { });
...or scan one or more assemblies for every closed IRequestHandler<>/IRequestHandler<,> implementation and register them all at once — no extra package required, this is built in:
services.AddRequestHandlersFromAssemblies(ServiceLifetime.Scoped, typeof(Program).Assembly);
// or across multiple assemblies, with whatever lifetime you need:
services.AddRequestHandlersFromAssemblies(ServiceLifetime.Scoped, typeof(Program).Assembly, typeof(SomeOtherModule).Assembly);
services.AddDefaultRequestSender(options => { });
Calling it more than once, or passing overlapping assemblies, won't produce duplicate registrations. Note it only picks up closed, concrete handler classes — an open-generic handler (e.g. class FallbackHandler<TRequest> : IRequestHandler<TRequest>) isn't discovered and must still be registered by hand: services.AddScoped(typeof(IRequestHandler<>), typeof(FallbackHandler<>));.
AddDefaultRequestSender registers IRequestSender (as ServiceLifetime.Scoped by default) backed by DefaultRequestSender.
3. Send requests
public sealed class SomeApplicationService(IRequestSender requestSender)
{
public async Task RunAsync(Guid userId, CancellationToken cancellationToken)
{
await requestSender.SendAsync(new PingCommand(), cancellationToken);
UserDto user = await requestSender.SendAsync(new GetUserQuery { UserId = userId }, cancellationToken);
}
}
Middleware pipeline
A middleware wraps the next handler in the chain and decides whether/when to call it — same idea as ASP.NET Core middleware, but per request type.
You don't register middleware classes in DI. The pipeline constructs them directly via
ActivatorUtilities, passing the next handler explicitly and resolving any other constructor parameter (likeILogger<>below) from the container. All you register in DI are the middleware's own dependencies, if any — the middleware type itself is only ever passed toUseRequestMiddleware/UseRequestResponseMiddleware, never toservices.Add....
public sealed class LoggingMiddleware<TRequest> : RequestMiddleware<TRequest>
where TRequest : IRequest
{
private readonly ILogger<LoggingMiddleware<TRequest>> _logger;
public LoggingMiddleware(IRequestHandler<TRequest> nextHandler, ILogger<LoggingMiddleware<TRequest>> logger)
: base(nextHandler)
{
_logger = logger;
}
public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Handling {RequestType}", typeof(TRequest).Name);
await _nextHandler.HandleAsync(request, cancellationToken);
_logger.LogInformation("Handled {RequestType}", typeof(TRequest).Name);
}
}
Register it as an open generic type:
services.AddDefaultRequestSender(options => options
.UseRequestMiddleware(typeof(LoggingMiddleware<>)));
The request/response variant works the same way, just implementing RequestMiddleware<TRequest, TResponse> and registering with UseRequestResponseMiddleware(typeof(YourMiddleware<,>)).
Execution order: middlewares run in the order they're registered — the first one registered is the outermost, so it runs first on the way in and last on the way out (a normal "onion" pipeline):
options
.UseRequestMiddleware(typeof(LoggingMiddleware<>)) // runs 1st, then last
.UseRequestMiddleware(typeof(ValidationMiddleware<>)); // runs 2nd, then first
ClearRequestMiddlewares() / ClearRequestResponseMiddlewares() reset the pipeline built so far if you need to override it conditionally.
Extensibility: custom senders
DefaultRequestSender is just the built-in implementation. If you need different behavior at the sender level (rather than as a middleware), derive from RequestSender<TOptions> and register it with the generic overload:
services.AddRequestSender<MyRequestSender, MyRequestSenderOptionsBuilder, MyRequestSenderOptions>(options => { });
Cancellation
CancellationToken passed to SendAsync flows through every middleware and reaches the handler's HandleAsync. Make sure any custom middleware you write forwards the token it receives to _nextHandler.HandleAsync(request, cancellationToken) instead of dropping it.
Observability
Every SendAsync call starts an Activity (named Send {RequestTypeName}) on an ActivitySource named after the assembly (LoomKit.Requests), tagged with request.type and, for request/response calls, response.type. If a handler or middleware throws, the exception is recorded on the activity via Activity.AddException (standard OpenTelemetry semantic conventions), including its message and stack trace.
⚠️ If your tracing backend doesn't have the same access controls as your application logs, avoid throwing exceptions from handlers whose Message carries secrets or personal data — they will flow into your trace exporter as-is.
License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- LoomKit.Requests.Abstractions (>= 10.0.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.10)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.