TCIS.EventBus
1.0.0-rc.18
See the version list below for details.
dotnet add package TCIS.EventBus --version 1.0.0-rc.18
NuGet\Install-Package TCIS.EventBus -Version 1.0.0-rc.18
<PackageReference Include="TCIS.EventBus" Version="1.0.0-rc.18" />
<PackageVersion Include="TCIS.EventBus" Version="1.0.0-rc.18" />
<PackageReference Include="TCIS.EventBus" />
paket add TCIS.EventBus --version 1.0.0-rc.18
#r "nuget: TCIS.EventBus, 1.0.0-rc.18"
#:package TCIS.EventBus@1.0.0-rc.18
#addin nuget:?package=TCIS.EventBus&version=1.0.0-rc.18&prerelease
#tool nuget:?package=TCIS.EventBus&version=1.0.0-rc.18&prerelease
TCIS.EventBus
Transactional messaging for the TCIS ecosystem: an outbox that writes the message in the same transaction as your business data, a background sender, and a consumer side with retries and a poison-message path.
Transport bindings live in separate packages — see
TCIS.EventBus.Kafka. For choosing betweenmediator.Publishand the EventBus, seeTCIS.Mediator.
Table of contents
| Section | Contents |
|---|---|
| 1 | Registration |
| 2 | Publishing |
| 3 | Subscribing |
| 4 | Headers and context propagation |
| 5 | ⚠️ What happens to a message that fails |
| 6 | Consuming from a third-party producer |
| 7 | Storage choices |
| 8 | Delivery guarantees |
| 9 | Options reference |
| 10 | Pitfalls |
1. Registration
builder.Services.AddTEventBus(options =>
{
builder.Configuration.GetSection("TCIS:EventBus").Bind(options);
options.UseSqlServer(connectionString); // outbox lives next to your data
options.UseKafka(kafka =>
builder.Configuration.GetSection("TCIS:EventBus:Kafka").Bind(kafka));
});
Three things must be registered or startup throws: the bus itself, one storage provider, and one transport provider.
{
"TCIS": {
"EventBus": {
"DefaultGroupName": "tcis.queue.vessel-service",
"Version": "v1",
"FailedRetryCount": 10,
"FailedRetryInterval": 30,
"SucceedMessageExpiredAfter": 86400,
"ConsumerShutdownTimeoutSeconds": 5,
"Kafka": {
"Servers": "broker:9092",
"MainConfig": { "group.id": "tcis.subscriber.vessel-group" }
}
}
}
}
2. Publishing
public sealed class BerthVesselHandler(IEventPublisher bus, VesselDbContext db)
{
public async Task Handle(BerthVessel cmd, CancellationToken ct)
{
// The bus joins the EF transaction: the outbox row and the business row commit together.
using var tx = db.Database.BeginTransaction(bus, autoCommit: false);
visit.Status = "BERTHED";
await db.SaveChangesAsync(ct);
await bus.PublishAsync("vessel.berthed", new VesselBerthed(visit.Id, visit.BerthCode));
await tx.CommitAsync(ct);
}
}
BeginTransaction(publisher, autoCommit) is an extension shipped by the storage package
(TCIS.EventBus.SqlServer, TCIS.EventBus.PostgreSql); overloads exist for DatabaseFacade and
for a raw IDbConnection.
The point of the outbox is that PublishAsync does not talk to the broker. It writes a row in
the same transaction. If the transaction rolls back, the message was never published. If the process
dies after commit but before send, a background processor picks the row up and sends it.
Publishing outside a transaction is allowed and sends immediately after persisting — use it only when there is no business data to keep in step.
3. Subscribing
public sealed class VesselSubscriber(IVesselService service) : IEventSubscribe
{
[EventSubscribe("vessel.berthed")]
public async Task OnBerthed(VesselBerthed evt, CancellationToken ct)
{
await service.RecordBerthingAsync(evt.VisitId, evt.BerthCode, ct);
}
}
builder.Services.AddScoped<VesselSubscriber>(); // discovery reads DI registrations
Subscribers are discovered from the service collection, so the class must be registered — an unregistered subscriber is not an error, it simply never runs.
Each subscriber runs in its own DI scope, not the publisher's. A throwing subscriber does not affect the publisher; it affects only its own message.
4. Headers and context propagation
Ambient context travels in headers, so subscribers do not need it in the payload:
| Header | Carries |
|---|---|
t-msg-id |
Message identity — the key for consumer-side idempotency |
t-msg-name |
Topic |
t-msg-group |
Internal group used to find the subscriber — not the Kafka group.id |
t-tenant-id, t-site-code |
Multi-tenancy context, restored into IWorkContext |
t-user-id, t-username, t-caller |
Audit context |
t-corr-id, traceparent |
Correlation and distributed tracing |
Keep the DTO clean — do not copy TenantId or SiteCode into the payload.
5. ⚠️ What happens to a message that fails
Three distinct outcomes. Confusing them is the usual reason an incident takes hours instead of minutes.
The subscriber throws
Retried in place up to 3 times, then handed to the background retry processor, which retries every
FailedRetryInterval seconds up to FailedRetryCount times. After that the message stays Failed
in storage and FailedThresholdCallback fires.
The message cannot be routed or deserialized
No subscriber matches the topic+group, or the payload does not parse. Retrying cannot help — the
next attempt fails identically. The message is stored as an exception message, the offset is
committed, and the stream continues. One Error log line records the reason:
Message parked and skipped: it could not be routed or deserialized, so the offset was committed
to let the stream continue. Id:…, Name:…, Group:…, Reason:SubscriberNotFoundException-->…
The payload parses but does not match the DTO
Nothing is reported. System.Text.Json ignores unknown JSON properties and leaves absent ones
at their default, so the subscriber receives a half-empty object and the message is marked
Succeeded. Property names are matched case-insensitively — but a genuinely different name
(vessel_name versus VesselName) still will not bind.
This one has no library-level defence. Validate required fields inside the subscriber:
if (string.IsNullOrWhiteSpace(evt.VesselName))
throw new TValidationException("VALIDATION_MISSING_VESSEL_NAME", "vessel_name is empty");
That converts a silent empty row into a parked message with a log line.
6. Consuming from a third-party producer
External producers do not emit TCIS headers. The transport fills in what is missing — see the
Kafka README for how
t-msg-id is derived and why it stays stable across redeliveries.
Two things to get right on the TCIS side:
- Do not set
TopicNamePrefix. It would rewrite the partner's topic name intoprefix.partner.topic, which does not exist. - Match the payload explicitly. Declare
[JsonPropertyName("...")]on the DTO rather than hoping the partner's naming matches yours. When the schema is not yet known, take aJsonElementfirst and lograw.GetRawText()to see exactly what arrives.
7. Storage choices
| SQL Server / PostgreSQL | InMemory | |
|---|---|---|
| Outbox survives restart | ✅ | ❌ un-sent messages are lost |
| Retry state survives restart | ✅ | ❌ |
| Parked messages queryable | ✅ | ❌ RAM only |
| Cluster-wide retry locking | ✅ via UseStorageLock |
❌ |
InMemory is for development and for stateless gateways where losing an un-sent message is
acceptable. Anything with business meaning belongs in a durable store.
Two InMemory specifics worth knowing:
- Messages are held for
SucceedMessageExpiredAfter(24 h by default) before the collector removes them. That is 24 hours of messages resident in RAM — lower it for a high-volume consumer. - The dictionaries are
static, so every host in the same process shares one store. This is fine for a single application, but it means two hosts side by side (or parallel integration tests) see each other's messages.
8. Delivery guarantees
At-least-once, with one condition. The offset is committed only after the subscriber finishes, so a crash mid-processing means the broker redelivers.
That condition breaks when EnableSubscriberParallelExecute = true: messages are then handed to an
in-memory channel and the offset is committed straight away. A crash loses whatever is still in the
channel. Enable it only when the throughput matters more than those messages do.
Because delivery is at-least-once, subscribers must be idempotent. Use t-msg-id as the
deduplication key.
9. Options reference
| Option | Default | Notes |
|---|---|---|
DefaultGroupName |
tcis.queue.{entry-assembly} |
Suffixed with .{Version} to form the internal group |
Version |
v1 |
Bump to run a new consumer generation alongside the old one |
FailedRetryCount |
50 | Total attempts before a message is left Failed |
FailedRetryInterval |
60 | Seconds between background retries |
SucceedMessageExpiredAfter |
86400 | Seconds a succeeded message is retained |
FailedMessageExpiredAfter |
1296000 | 15 days |
ConsumerThreadCount |
1 | Listening threads per group |
ConsumerShutdownTimeoutSeconds |
5 | Keep below the host's own shutdown timeout |
EnableSubscriberParallelExecute |
false | See §8 before enabling |
UseStorageLock |
false | Required for correct retry behaviour across multiple instances |
TopicNamePrefix |
null | Never set this when consuming a partner's topic |
JsonSerializerOptions |
case-insensitive | Aligned with ASP.NET Core; see §5 |
10. Pitfalls
| # | Pitfall | Consequence |
|---|---|---|
| 1 | Forgetting to register the subscriber class in DI | It is never discovered and never runs — silently |
| 2 | Expecting PublishAsync to reach the broker immediately |
It writes to the outbox; the send happens afterwards |
| 3 | Publishing after the transaction commits | Loses the atomicity the outbox exists to provide |
| 4 | Assuming a subscriber failure fails the publisher | It does not — separate scope, separate lifetime |
| 5 | Non-idempotent subscribers | At-least-once delivery will double-process on redelivery |
| 6 | Confusing t-msg-group with the Kafka group.id |
Overwriting the former makes every message unroutable |
| 7 | Setting TopicNamePrefix for a partner topic |
Subscribes to a topic that does not exist |
| 8 | Trusting that a parsed payload is a complete one | Missing fields bind to defaults with no error — validate in the subscriber |
| 9 | InMemory storage in production | Un-sent outbox rows and retry state are lost on restart |
| 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
- Microsoft.Extensions.Configuration.Abstractions (>= 9.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Options (>= 9.0.0)
- TCIS.Core (>= 1.0.0-rc.18)
- TCIS.Logging.Abstractions (>= 1.0.0-rc.18)
NuGet packages (5)
Showing the top 5 NuGet packages that depend on TCIS.EventBus:
| Package | Downloads |
|---|---|
|
TCIS.EventBus.InMemoryStorage
TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. In-memory storage provider for TCIS Event Bus. |
|
|
TCIS.EventBus.Kafka
TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. Kafka provider for TCIS Event Bus. |
|
|
TCIS.EventBus.SqlServer
TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. SQL Server storage provider for TCIS Event Bus. |
|
|
TCIS.EventBus.PostgreSql
TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. PostgreSQL storage provider for TCIS Event Bus. |
|
|
TCIS.EventBus.RabbitMQ
TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. RabbitMQ provider for TCIS Event Bus. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-rc.19 | 47 | 8/13/2026 |
| 1.0.0-rc.18 | 46 | 8/13/2026 |
| 1.0.0-rc.17 | 47 | 8/13/2026 |
| 1.0.0-rc.16 | 49 | 8/13/2026 |
| 1.0.0-rc.15 | 60 | 8/12/2026 |
| 1.0.0-rc.14 | 61 | 8/12/2026 |
| 1.0.0-rc.13 | 59 | 8/11/2026 |
| 1.0.0-rc.12 | 69 | 8/10/2026 |
| 1.0.0-rc.11 | 83 | 7/28/2026 |
| 1.0.0-rc.10 | 79 | 7/24/2026 |
| 1.0.0-rc.9 | 72 | 7/21/2026 |
| 1.0.0-rc.8 | 75 | 7/21/2026 |
| 1.0.0-rc.7 | 82 | 7/17/2026 |
| 1.0.0-rc.6 | 89 | 7/7/2026 |
| 1.0.0-rc.5 | 92 | 7/7/2026 |
| 1.0.0-rc.4 | 92 | 6/24/2026 |
| 1.0.0-rc.2 | 89 | 5/12/2026 |
| 1.0.0-rc.1 | 95 | 5/12/2026 |