Kanject.Core.Events

View .md

Say "this happened" without choosing a transport. Declare an event with [EventTopic], publish it through IEventPublisher, and let the host decide where it goes: handlers in the same process by default, SNS through ServerlessEventHub, or your own broker. Free under the Community licence.

Install

bash
dotnet add package Kanject.Core.Eventsdotnet add package Kanject.Core.Events.Annotations   # the generator

A library that only declares events needs Kanject.Core.Events.Abstractions, which depends on nothing but the BCL and Microsoft.Extensions.DependencyInjection.Abstractions — so an event contract never drags a transport into the code that declares it. Targets .NET 8, 9 and 10.

Declare an event

csharp
using System.Text.Json.Serialization;using Kanject.Core.Events.Abstractions.Attributes;​[assembly: EventTopicOwner("orders-svc")]​[EventTopic("order_placed", Description = "An order was placed")]public sealed record OrderPlaced(string OrderId, int Lines);​// One per assembly: the source-generated serializer every event travels through.[EventJsonContext][JsonSerializable(typeof(OrderPlaced))]internal partial class OrderEventsJsonContext : JsonSerializerContext;
  • The topic is 1–200 lowercase letters, digits, _ and - — the same name on every transport (SNS refuses a . in a topic name).
  • Version (default 1) is the payload contract's version. Two versions of one topic can be declared side by side, each with its own payload type.
  • Owner names the generated class (orders-svc → OrdersSvcEvents); [assembly: EventTopicOwner] sets it for every event that names none.
  • Exactly one [EventJsonContext] per assembly lists every event with [JsonSerializable]. Its options decide the JSON on the wire.

Publish

The generator writes a typed Publish{Name}Async for each event and one Add{Owner}Events() per owner. EventMetadata carries what a caller may set: the event id (minted if absent), the tenant, when it occurred, and up to five extra attributes.

csharp
// Generated from the owner: the declarations, into the container's event catalog.services.AddOrdersSvcEvents();​public class CheckoutService(IEventPublisher publisher){    public Task PlaceAsync(Order order) =>        publisher.PublishOrderPlacedAsync(            new OrderPlaced(order.Id, order.Lines.Count),            new EventMetadata { TenantId = order.TenantId });}

Handle it in-process

AddEvents() registers the in-process publisher and its handlers. It only registers the publisher when no other is registered, so a library can call it safely: a host that registers a real transport keeps it, in either order.

csharp
services.AddEvents(events => events    .On(OrdersSvcEvents.OrderPlaced).Handle<SendConfirmation>()   // typed    .OnTopic("order_placed").Handle<AuditTrail>()                 // raw: the envelope and its JSON    .OnAnyTopic().Handle<EventLog>());                            // every topic​public sealed class SendConfirmation(IMailer mailer) : IEventHandler<OrderPlaced>{    public Task HandleAsync(OrderPlaced @event, EventEnvelope envelope,        CancellationToken cancellationToken) =>        mailer.SendConfirmationAsync(@event.OrderId, cancellationToken);}
  • Handlers run before PublishAsync completes, in registration order, one at a time, each in its own DI scope.
  • A failing handler is logged (KANEVT062) and the next one runs. With HandlerFailureMode.Throw, every handler still runs and the failures are thrown together.
  • A handler that publishes runs inline, up to MaxDispatchDepth (8 by default).
  • A topic with no handler is dropped and logged once (KANEVT060).

Choose the transport

AddEventPublisher<T>() registers the host's publisher and replaces any other, so exactly one remains whatever the registration order. The in-process handlers then stop running — the transport delivers instead.

csharp
// SNS, through BaaS ServerlessEventHubservices.AddServerlessEventHubEventPublisher("orders-svc");​// …or your own broker: write envelope.Payload as the body and// envelope.GetHeaders() as the headers, then prove it with the TestKit.services.AddEventPublisher<KafkaEventPublisher>();

Every publisher keeps one contract, and Kanject.Core.Events.TestKit checks it: completion means the transport accepted the event; failure throws, never returns; the envelope goes out unchanged; a retried event keeps its id, so consumers can deduplicate on it. Kanject BaaS modules — Identity, Wallet, Workflow and others — publish their product events through this contract, so the one publisher a host registers carries theirs too.

On the wire

  • EventTopic — the topic
  • EventId — the event id (evt_ and 32 hex digits unless you set one)
  • EventVersion — the payload contract's version
  • OccurredAt — ISO 8601, UTC
  • kanject-tenant-id — only when a tenant is set
  • The body is the payload's JSON, byte for byte.

What ships with it

  • Kanject.Core.Events.Abstractions — [EventTopic], IEventPublisher, the envelope, handlers and the per-container IEventCatalog
  • Kanject.Core.Events — the in-process default publisher and handler registration
  • Kanject.Core.Events.Annotations — the generator: typed publishers, definitions, Add{Owner}Events(), and an <Assembly>.eventtopicschema.md catalogue of every event
  • Kanject.Core.Events.TestKit — the contract suite every publisher passes, and a recording test double
  • Reflection-free and IsAotCompatible: payloads serialize only through your source-generated context
Was this page helpful?