GMana.Bus
0.0.2
dotnet add package GMana.Bus --version 0.0.2
NuGet\Install-Package GMana.Bus -Version 0.0.2
<PackageReference Include="GMana.Bus" Version="0.0.2" />
<PackageVersion Include="GMana.Bus" Version="0.0.2" />
<PackageReference Include="GMana.Bus" />
paket add GMana.Bus --version 0.0.2
#r "nuget: GMana.Bus, 0.0.2"
#:package GMana.Bus@0.0.2
#addin nuget:?package=GMana.Bus&version=0.0.2
#tool nuget:?package=GMana.Bus&version=0.0.2
GMana.Bus
Lightweight in-process request, notification, and pipeline dispatching for .NET applications.
GMana.Bus is useful when you want a small mediator-style abstraction for CQRS, application commands, queries, domain notifications, and cross-cutting request behaviors such as logging, validation, caching, or transactions.
Features
- Request/response dispatching through
IRequest<TResponse>andIRequestHandler<TRequest, TResponse> - Command dispatching with no payload through
IRequest,IRequestHandler<TRequest>, andUnit - In-process notifications with any number of
INotificationHandler<TNotification>handlers - Open-generic request pipeline behaviors
- Assembly scanning for handlers
- Microsoft dependency injection integration
ValueTask-based APIs
Install
dotnet add package GMana.Bus
Quick Start
Register the bus and scan the assembly that contains your handlers:
using GMana.Bus;
builder.Services.AddGManaBus(typeof(Program).Assembly);
Create a request and handler:
public sealed record GetProductQuery(Guid Id) : IRequest<ProductDto?>;
public sealed class GetProductQueryHandler
: IRequestHandler<GetProductQuery, ProductDto?>
{
public ValueTask<ProductDto?> Handle(
GetProductQuery request,
CancellationToken cancellationToken)
{
ProductDto? product = null;
// Load the product here.
return ValueTask.FromResult(product);
}
}
Inject ISender where you need to execute requests:
public sealed class ProductsEndpoint(ISender sender)
{
public async Task<IResult> Get(Guid id, CancellationToken cancellationToken)
{
ProductDto? product = await sender.Send(
new GetProductQuery(id),
cancellationToken);
return product is null ? Results.NotFound() : Results.Ok(product);
}
}
Registration
AddGManaBus scans one or more assemblies for request and notification handlers. Handlers are
registered as scoped services. ISender, IPublisher, and the internal dispatcher are also scoped.
builder.Services.AddGManaBus(
typeof(Program).Assembly,
typeof(SomeApplicationHandler).Assembly);
Each request type must have exactly one handler. Registering multiple handlers for the same request throws during startup. Notifications may have zero, one, or many handlers.
Requests
A request represents work that returns a value:
public sealed record CreateOrderCommand(Guid CustomerId) : IRequest<Guid>;
public sealed class CreateOrderCommandHandler
: IRequestHandler<CreateOrderCommand, Guid>
{
public ValueTask<Guid> Handle(
CreateOrderCommand request,
CancellationToken cancellationToken)
{
Guid orderId = Guid.NewGuid();
// Create the order here.
return ValueTask.FromResult(orderId);
}
}
Send the request with ISender:
Guid orderId = await sender.Send(
new CreateOrderCommand(customerId),
cancellationToken);
If no handler is registered for the request type, Send throws an InvalidOperationException.
Commands Without a Response
Use IRequest when a command has no response payload. The bus represents the response as Unit.
public sealed record ArchiveOrderCommand(Guid OrderId) : IRequest;
public sealed class ArchiveOrderCommandHandler
: IRequestHandler<ArchiveOrderCommand>
{
public ValueTask<Unit> Handle(
ArchiveOrderCommand request,
CancellationToken cancellationToken)
{
// Archive the order here.
return ValueTask.FromResult(Unit.Value);
}
}
await sender.Send(new ArchiveOrderCommand(orderId), cancellationToken);
Notifications
Notifications represent fire-and-forget in-process events. They can have multiple handlers.
public sealed record ProductCreatedNotification(Guid ProductId) : INotification;
public sealed class ProductCreatedNotificationHandler
: INotificationHandler<ProductCreatedNotification>
{
public ValueTask Handle(
ProductCreatedNotification notification,
CancellationToken cancellationToken)
{
// React to the notification here.
return ValueTask.CompletedTask;
}
}
Inject IPublisher and publish the notification:
await publisher.Publish(
new ProductCreatedNotification(productId),
cancellationToken);
Notification handlers run sequentially in dependency injection registration order. Publishing a notification with no registered handlers completes successfully.
Pipeline Behaviors
Pipeline behaviors wrap request handlers. They are only applied to requests, not notifications.
Register open-generic behaviors with AddPipelineBehavior:
builder.Services.AddPipelineBehavior(typeof(LoggingBehavior<,>));
builder.Services.AddPipelineBehavior(typeof(ValidationBehavior<,>));
Behaviors run in registration order. The first registered behavior is the outermost behavior.
LoggingBehavior
ValidationBehavior
RequestHandler
public sealed class LoggingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async ValueTask<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TRequest, TResponse> next,
CancellationToken cancellationToken)
{
Console.WriteLine($"Handling {typeof(TRequest).Name}");
TResponse response = await next(request, cancellationToken);
Console.WriteLine($"Handled {typeof(TRequest).Name}");
return response;
}
}
Pipeline behaviors should call next at most once and should not call it concurrently.
Behavior Recipes
Logging
using System.Diagnostics;
using GMana.Bus;
public sealed class LoggingBehavior<TRequest, TResponse>(
ILogger<LoggingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async ValueTask<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TRequest, TResponse> next,
CancellationToken cancellationToken)
{
string requestName = typeof(TRequest).Name;
long startTimestamp = Stopwatch.GetTimestamp();
logger.LogInformation("Handling {RequestName}", requestName);
try
{
TResponse response = await next(request, cancellationToken);
logger.LogInformation(
"Handled {RequestName} in {Elapsed}ms",
requestName,
Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds);
return response;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Handler {RequestName} threw after {Elapsed}ms",
requestName,
Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds);
throw;
}
}
}
builder.Services.AddPipelineBehavior(typeof(LoggingBehavior<,>));
Validation
This example uses FluentValidation and validates only request types that have registered validators.
using FluentValidation;
using FluentValidation.Results;
using GMana.Bus;
public sealed class ValidationBehavior<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IValidator<TRequest>[] validators = validators.ToArray();
public async ValueTask<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TRequest, TResponse> next,
CancellationToken cancellationToken)
{
if (validators.Length == 0)
{
return await next(request, cancellationToken);
}
ValidationContext<TRequest> context = new(request);
List<ValidationFailure> failures = [];
foreach (IValidator<TRequest> validator in validators)
{
ValidationResult result = await validator.ValidateAsync(context, cancellationToken);
failures.AddRange(result.Errors);
}
if (failures.Count > 0)
{
throw new ValidationException(failures);
}
return await next(request, cancellationToken);
}
}
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
builder.Services.AddPipelineBehavior(typeof(ValidationBehavior<,>));
Caching
Use an opt-in marker interface when only some requests should be cached.
public interface ICacheable
{
string CacheKey { get; }
TimeSpan? Expiration => null;
}
using GMana.Bus;
using Microsoft.Extensions.Caching.Hybrid;
public sealed class CachingBehavior<TRequest, TResponse>(HybridCache cache)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public ValueTask<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TRequest, TResponse> next,
CancellationToken cancellationToken)
{
if (request is not ICacheable cacheable)
{
return next(request, cancellationToken);
}
HybridCacheEntryOptions? options = cacheable.Expiration is { } expiration
? new HybridCacheEntryOptions { Expiration = expiration }
: null;
return cache.GetOrCreateAsync(
cacheable.CacheKey,
(Request: request, Next: next),
static (state, ct) => state.Next(state.Request, ct),
options,
cancellationToken: cancellationToken);
}
}
builder.Services.AddHybridCache();
builder.Services.AddPipelineBehavior(typeof(CachingBehavior<,>));
Transactions
Use an opt-in marker interface when only some commands should be wrapped in a transaction.
public interface ITransactional;
using GMana.Bus;
using Microsoft.EntityFrameworkCore.Storage;
public sealed class TransactionBehavior<TRequest, TResponse>(AppDbContext db)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public ValueTask<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TRequest, TResponse> next,
CancellationToken cancellationToken)
{
return request is ITransactional
? HandleTransactional(next, request, cancellationToken)
: next(request, cancellationToken);
}
private async ValueTask<TResponse> HandleTransactional(
RequestHandlerDelegate<TRequest, TResponse> next,
TRequest request,
CancellationToken cancellationToken)
{
await using IDbContextTransaction tx = await db.Database
.BeginTransactionAsync(cancellationToken);
try
{
TResponse response = await next(request, cancellationToken);
await tx.CommitAsync(cancellationToken);
return response;
}
catch
{
await tx.RollbackAsync(cancellationToken);
throw;
}
}
}
builder.Services.AddPipelineBehavior(typeof(TransactionBehavior<,>));
Design Notes
- This is an in-process dispatcher. It is not a message broker or queue.
- Request handler lookup uses the runtime request type.
- A request has one handler. A notification can have many handlers.
- Notification handlers run sequentially.
- Pipeline behaviors apply only to requests.
- All handlers and behaviors are resolved from the current dependency injection scope.
| 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
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.