TCIS.EventBus 1.0.0-rc.20

This is a prerelease version of TCIS.EventBus.
dotnet add package TCIS.EventBus --version 1.0.0-rc.20
                    
NuGet\Install-Package TCIS.EventBus -Version 1.0.0-rc.20
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="TCIS.EventBus" Version="1.0.0-rc.20" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="TCIS.EventBus" Version="1.0.0-rc.20" />
                    
Directory.Packages.props
<PackageReference Include="TCIS.EventBus" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add TCIS.EventBus --version 1.0.0-rc.20
                    
#r "nuget: TCIS.EventBus, 1.0.0-rc.20"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package TCIS.EventBus@1.0.0-rc.20
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=TCIS.EventBus&version=1.0.0-rc.20&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=TCIS.EventBus&version=1.0.0-rc.20&prerelease
                    
Install as a Cake Tool

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 between mediator.Publish and the EventBus, see TCIS.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:

  1. Do not set TopicNamePrefix. It would rewrite the partner's topic name into prefix.partner.topic, which does not exist.
  2. 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 a JsonElement first and log raw.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, TGuard at 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.cs and KafkaConsumerClient.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.Client and 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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