TCIS.EventBus
1.0.0-rc.20
dotnet add package TCIS.EventBus --version 1.0.0-rc.20
NuGet\Install-Package TCIS.EventBus -Version 1.0.0-rc.20
<PackageReference Include="TCIS.EventBus" Version="1.0.0-rc.20" />
<PackageVersion Include="TCIS.EventBus" Version="1.0.0-rc.20" />
<PackageReference Include="TCIS.EventBus" />
paket add TCIS.EventBus --version 1.0.0-rc.20
#r "nuget: TCIS.EventBus, 1.0.0-rc.20"
#:package TCIS.EventBus@1.0.0-rc.20
#addin nuget:?package=TCIS.EventBus&version=1.0.0-rc.20&prerelease
#tool nuget:?package=TCIS.EventBus&version=1.0.0-rc.20&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.
This package is a fork of DotNetCore.CAP, not original code. Fork point and divergence are recorded in section 11. Read it before reporting a bug or reasoning about behaviour that is not covered by our own tests.
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 |
| 11 | ๐ Provenance โ fork point |
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 |
11. Provenance โ fork point
This package and its transport/storage siblings are a fork of DotNetCore.CAP, not original TCIS code. The fork exists because TCIS needed hooks inside CAP's consumer lifecycle that CAP does not expose โ see ยง11.3.
11.1. The fork point
| Upstream | https://github.com/dotnetcore/CAP.git, branch master |
| Commit | d3f536489b1200f46c85aed9efbfbe40a6853709 (d3f53648) |
| Date | 2026-02-10 |
git describe |
v10.0.1-7-gd3f53648 โ 7 commits after tag v10.0.1 |
| Licence | MIT ยฉ 2016 Savorboard โ see LICENSE.txt, kept verbatim as the licence requires |
Reproduce the comparison with:
git clone https://github.com/dotnetcore/CAP.git && cd CAP
git worktree add --detach ../CAPfork d3f53648
git log --oneline d3f53648..origin/master # what we are behind
11.2. State as measured on 2026-08-18
Upstream master |
e52b8508, 2026-07-01, tag v10.0.2 |
| Commits behind | 10 |
Changes upstream in src/DotNetCore.CAP/ (the core we forked) |
none โ zero lines |
| Our divergence, after normalising the rename and the logging/guard swap | ~1,021 lines of code across 6 packages (~7.7%) |
| Files identical in name to upstream | 76 / 87 in the core |
The 10 upstream commits are documentation, the Kubernetes dashboard, an AmazonSQS null-response fix, sample removal, and dependency bumps. None of them touches code we carry.
The only upstream change that concerns us is the dependency baseline:
| Package | at d3f53648 |
at v10.0.2 |
|---|---|---|
Confluent.Kafka |
2.12.0 | 2.14.2 |
Npgsql |
9.0.4 | 10.0.2 |
Microsoft.Data.SqlClient |
6.1.3 | 7.0.1 |
RabbitMQ.Client |
7.2.0 | 7.2.1 |
Plus one commit removing System.Linq.Async for .NET 10 support.
11.3. Why the fork exists
Three needs, none of which CAP exposes an extension point for:
| # | Need | Where it lands |
|---|---|---|
| 1 | Prepare ambient context between CreateAsyncScope() and GetInstance() โ the subscriber's DbContext picks its tenant connection string in its own constructor, so anything later is too late |
IConsumerContextInitializer (new file) + Internal/ISubscribeInvoker.Default.cs |
| 2 | Restore WorkContext from headers, and classify permanent failures by TCIS error-code prefix (SEC_, CONFIG_) so configuration errors are not retried |
Internal/ISubscribeExector.Default.cs |
| 3 | Attach context headers when publishing | Internal/IEventPublisher.Default.cs |
IConsumerContextInitializer is deliberately neutral โ it does not know the word "tenant" โ so the
multi-tenant behaviour lives outside this package, in
TCIS.EventBus.MultiTenancy. That package is original TCIS
code, not forked.
Everything else in the diff is one of two things:
- Modernisation โ C# 12 primary constructors,
ILoggerโILogWriter,TGuardat constructor boundaries. Wide, mechanical, no behavioural change. Recognisable by touching many lines while adding no comments. - Defect fixes โ 18 of them, August 2026. Recognisable by code and Vietnamese explanatory comments
arriving together. Concentrated in
IConsumerRegister.Default.cs,IDispatcher.Default.cs,ISubscribeExector.Default.csandKafkaConsumerClient.cs.
None of the 18 fixes exists upstream. As of v10.0.2, ISubscribeExector.Default.cs on master
still contains:
catch (OperationCanceledException)
{
//ignore // a cancelled handler is still recorded as Succeeded โ silent data loss
}
11.4. How to work with this fork
- Hitting a bug? Check whether upstream already fixed it before debugging: compare the file against
d3f53648..origin/master. Today that range is empty for the core, so the answer is almost certainly "no" โ but re-check, because that will not stay true. - Upgrading a transport/storage dependency? Take the version upstream is on (table in ยง11.2) rather than picking one yourself; upstream tests against those combinations.
- Security advisories for
Confluent.Kafka,Npgsql,Microsoft.Data.SqlClient,RabbitMQ.Clientand CAP itself apply to us and must be ported by hand. - Do not renumber or rename further. The identifier rename (
Cap*โEventBus*/T*) is already the main cost of any future re-base. It is mechanical and reversible with a script; widening it is not. - Keep this section current. Re-run the two commands in ยง11.1 when you touch this package, and update ยง11.2. A fork whose distance from upstream is unknown cannot be maintained.
| 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.20)
- TCIS.Logging.Abstractions (>= 1.0.0-rc.20)
NuGet packages (6)
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.RabbitMQ
TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. RabbitMQ 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. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-rc.20 | 0 | 8/18/2026 |
| 1.0.0-rc.19 | 64 | 8/13/2026 |
| 1.0.0-rc.18 | 63 | 8/13/2026 |
| 1.0.0-rc.17 | 63 | 8/13/2026 |
| 1.0.0-rc.16 | 63 | 8/13/2026 |
| 1.0.0-rc.15 | 69 | 8/12/2026 |
| 1.0.0-rc.14 | 69 | 8/12/2026 |
| 1.0.0-rc.13 | 63 | 8/11/2026 |
| 1.0.0-rc.12 | 75 | 8/10/2026 |
| 1.0.0-rc.11 | 84 | 7/28/2026 |
| 1.0.0-rc.10 | 81 | 7/24/2026 |
| 1.0.0-rc.9 | 73 | 7/21/2026 |
| 1.0.0-rc.8 | 76 | 7/21/2026 |
| 1.0.0-rc.7 | 83 | 7/17/2026 |
| 1.0.0-rc.6 | 90 | 7/7/2026 |
| 1.0.0-rc.5 | 93 | 7/7/2026 |
| 1.0.0-rc.4 | 93 | 6/24/2026 |
| 1.0.0-rc.2 | 90 | 5/12/2026 |
| 1.0.0-rc.1 | 96 | 5/12/2026 |