TCIS.Mediator
1.0.0-rc.37
dotnet add package TCIS.Mediator --version 1.0.0-rc.37
NuGet\Install-Package TCIS.Mediator -Version 1.0.0-rc.37
<PackageReference Include="TCIS.Mediator" Version="1.0.0-rc.37" />
<PackageVersion Include="TCIS.Mediator" Version="1.0.0-rc.37" />
<PackageReference Include="TCIS.Mediator" />
paket add TCIS.Mediator --version 1.0.0-rc.37
#r "nuget: TCIS.Mediator, 1.0.0-rc.37"
#:package TCIS.Mediator@1.0.0-rc.37
#addin nuget:?package=TCIS.Mediator&version=1.0.0-rc.37&prerelease
#tool nuget:?package=TCIS.Mediator&version=1.0.0-rc.37&prerelease
TCIS.Mediator
A small CQRS mediator for the TCIS ecosystem: requests, notifications, streams, and a deliberately minimal pipeline.
Per-site handler routing lives in
TCIS.Mediator.Keyed.
Table of contents
| Section | Contents |
|---|---|
| 1 | Registration |
| 2 | Requests and handlers |
| 3 | The pipeline |
| 4 | Notifications |
| 5 | ⚠️ Publish vs EventBus — read before using either |
| 6 | Streams |
| 7 | Pitfalls |
1. Registration
dotnet add package TCIS.Mediator
builder.Services.AddTMediator(options =>
{
options.Assemblies = [typeof(Program).Assembly]; // scans handlers + validators
options.AddTEcosystem(); // tracing, then validation
});
Assembly scanning registers IRequestHandler<,>, INotificationHandler<>, IStreamRequestHandler<,> and FluentValidation IValidator<>.
IMediator is registered with TryAdd, so AddTMediator and AddTKeyedMediator may be called in either order without one silently overriding the other.
2. Requests and handlers
public sealed record CreateOrder(string OrderNo, string CustomerCode) : IRequest<Guid>;
public sealed class CreateOrderHandler(IUnitOfWork uow) : IRequestHandler<CreateOrder, Guid>
{
public async Task<Guid> Handle(CreateOrder request, CancellationToken ct)
{
var order = new Order { OrderNo = request.OrderNo };
uow.Repository<Order>().Add(order);
await uow.SaveChangesAsync(ct);
return order.Id;
}
}
// Call site
Guid id = await mediator.Send(new CreateOrder("ORD-001", "CUST-01"), ct);
Use IRequest (no type argument) for commands with no return value — it is IRequest<Unit>.
3. The pipeline
Registration order IS execution order, outer to inner. The behavior registered first ends up outermost.
AddTEcosystem() registers exactly two:
TracingBehavior ← outermost: the span covers the whole request
ValidationBehavior
handler ← innermost
Add your own with options.AddBehavior(typeof(MyBehavior<,>)) — placed according to where you register it.
Why the standard pipeline is this small
Four behaviors were removed after an audit found that nothing in the repository — including all three templates and the sample — had ever implemented them:
ExceptionHandlerBehaviorcould turn an infrastructure failure into a fake success, violating the business/infrastructure boundary.TExceptionMiddlewarealready maps exceptions by taxonomy.LoggingBehaviorlogged the whole request object at Information level — a login or change-password command would have written credentials to the log. Duration is already on the tracing span; 5xx logging is already in the middleware.PreProcessorBehavior/PostProcessorBehaviorran on every request to iterate an empty collection. Work that belongs before or after a handler reads better inside the handler.
Validation
ValidationBehavior runs every registered IValidator<TRequest>. On failure it throws
TValidationException with code VALIDATION_ERROR (HTTP 400) and per-field detail:
{
"isSuccess": false,
"code": "VALIDATION_ERROR",
"message": "Validation failed: Name is required.",
"data": [ { "field": "name", "message": "Name is required." } ]
}
It stays in the pipeline — rather than relying on the MVC validation filter — because it is the only place covering every ingress: MVC controllers, Minimal APIs, gRPC services, EventBus consumers and Hangfire jobs all reach the same handler (ARC-050). The MVC filter only runs for controller actions.
4. Notifications
public sealed record ContainerWeighed(Guid VisitId, string ContainerNo, int WeightKg) : INotification;
public sealed class UpdateVisitTotalHandler : INotificationHandler<ContainerWeighed> { … }
public sealed class WriteWeighAuditHandler : INotificationHandler<ContainerWeighed> { … }
await mediator.Publish(new ContainerWeighed(visitId, "TCIU1234567", 28400), ct);
Every registered handler runs, sequentially, in DI registration order.
Publishis not fire-and-forget. The correct mental model is:await mediator.Publish(evt, ct); // ≡ foreach (var h in handlers) await h.Handle(evt, ct);No queue, no separate scope, no retry. Handlers run on the caller's thread, in the caller's DI scope, sharing the caller's
DbContextand transaction. A handler that throws fails the caller, and later handlers do not run.
Sequential is not a limitation to work around: handlers share one DbContext, which is not thread-safe. Dispatching them concurrently produces "A second operation was started on this context".
5. ⚠️ Publish vs EventBus — read before using either
One question decides it:
If this handler fails, SHOULD the original command fail too?
Yes →
mediator.Publish· No → EventBus + Outbox
All four conditions must hold to use Publish. Break one and you need the EventBus:
| # | Condition | Otherwise |
|---|---|---|
| 1 | The handler writes to the same database as the caller | Another module, another DbContext — one transaction spans one context (ARC-021) |
| 2 | The handler does no I/O outside the database | HTTP, email, broker calls put network latency inside a transaction (DB-036) |
| 3 | The handler failing should fail the command | Otherwise you get a partial failure: data committed, client told it failed |
| 4 | Consistency is needed now, not eventually | Eventual is fine → the EventBus is cheaper and safer |
Where Publish genuinely earns its place
Several same-module reactions that must be atomic. The command does not need to know how many pieces of logic react.
An in-transaction extension point for plugins — the strongest reason in this stack. Platform publishes, a site plugin adds its own reaction, and both live or die together:
await mediator.Publish(new GateInCompleted(transactionId), ct); // Platform
[/* plugin assembly */]
public sealed class CatLaiSurchargeHandler : INotificationHandler<GateInCompleted> { … }
With TCIS.Mediator.Keyed, Platform and plugin handlers both react. The EventBus cannot do this — it is eventual by design, so the plugin's reaction would land outside the transaction and change the business meaning.
Where it does not
Do not reach for Publish merely to "decouple". If there is exactly one handler and it must run, call the service directly: you get a compile-time contract, a readable call flow, and a debugger that steps straight through.
Publish earns its keep through plurality and openness — an unknown, extensible set of reactors. Without that, it is a method call hidden behind indirection.
Decision table
| Task | Use |
|---|---|
| Weigh a container → update the visit total + write an audit row | Publish |
| Gate-in completes → a port plugin adds its own surcharge | Publish |
| Vessel arrives → another module reacts | EventBus |
| Invoice issued → send email, call the customs API | EventBus |
| Voyage closed → generate a report | EventBus |
6. Streams
public sealed record ExportOrders(DateOnly From) : IStreamRequest<OrderRow>;
await foreach (var row in mediator.CreateStream(new ExportOrders(from), ct))
{
await writer.WriteAsync(row);
}
Use for large exports where materialising the whole result would blow memory.
7. Pitfalls
| # | Pitfall | Consequence |
|---|---|---|
| 1 | Expecting Publish to be asynchronous / fire-and-forget |
It is a synchronous fan-out; a failing handler fails the caller |
| 2 | Calling Publish after committing the transaction |
Partial failure: data saved, client told it failed. Publish before the commit, or use the EventBus |
| 3 | Handlers that call HTTP/email inside Publish |
Network latency held inside a database transaction |
| 4 | Adding the same behavior twice | Guarded — AddBehavior is idempotent |
| 5 | Assuming a behavior registered last runs first | The opposite: first registered is outermost |
| 6 | Writing a validator for the bound DTO instead of the command | Only the HTTP path is protected; gRPC, consumers and jobs are not |
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. 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. |
-
net8.0
- FluentValidation (>= 11.11.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.0)
- TCIS.Core (>= 1.0.0-rc.37)
- TCIS.Logging.Abstractions (>= 1.0.0-rc.37)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on TCIS.Mediator:
| Package | Downloads |
|---|---|
|
TCIS.Mediator.Keyed
TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. Multi-tenant support for TCIS.Mediator using Keyed Services. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-rc.37 | 0 | 8/27/2026 |
| 1.0.0-rc.36 | 0 | 8/27/2026 |
| 1.0.0-rc.35 | 31 | 8/26/2026 |
| 1.0.0-rc.34 | 35 | 8/26/2026 |
| 1.0.0-rc.33 | 61 | 8/21/2026 |
| 1.0.0-rc.32 | 55 | 8/21/2026 |
| 1.0.0-rc.31 | 50 | 8/21/2026 |
| 1.0.0-rc.30 | 57 | 8/21/2026 |
| 1.0.0-rc.29 | 61 | 8/21/2026 |
| 1.0.0-rc.28 | 65 | 8/21/2026 |
| 1.0.0-rc.27 | 61 | 8/21/2026 |
| 1.0.0-rc.26 | 59 | 8/20/2026 |
| 1.0.0-rc.25 | 58 | 8/20/2026 |
| 1.0.0-rc.24 | 61 | 8/20/2026 |
| 1.0.0-rc.23 | 69 | 8/20/2026 |
| 1.0.0-rc.22 | 64 | 8/19/2026 |
| 1.0.0-rc.21 | 63 | 8/19/2026 |
| 1.0.0-rc.20 | 69 | 8/18/2026 |
| 1.0.0-rc.19 | 68 | 8/13/2026 |
| 1.0.0-rc.18 | 56 | 8/13/2026 |