Kanject.Core.Events
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
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
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.Ownernames 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.
// 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.
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 topicpublic sealed class SendConfirmation(IMailer mailer) : IEventHandler<OrderPlaced>{ public Task HandleAsync(OrderPlaced @event, EventEnvelope envelope, CancellationToken cancellationToken) => mailer.SendConfirmationAsync(@event.OrderId, cancellationToken);} - Handlers run before
PublishAsynccompletes, in registration order, one at a time, each in its own DI scope. - A failing handler is logged (
KANEVT062) and the next one runs. WithHandlerFailureMode.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.
// 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 topicEventId— the event id (evt_and 32 hex digits unless you set one)EventVersion— the payload contract's versionOccurredAt— ISO 8601, UTCkanject-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-containerIEventCatalogKanject.Core.Events— the in-process default publisher and handler registrationKanject.Core.Events.Annotations— the generator: typed publishers, definitions,Add{Owner}Events(), and an<Assembly>.eventtopicschema.mdcatalogue of every eventKanject.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