Kafka Event Bus
Distributed event bus using Apache Kafka via kafkajs, with at-least-once delivery and per-aggregate partition ordering.
The @noddde/kafka package wraps kafkajs to deliver events through Kafka topics with at-least-once semantics and per-aggregate partition ordering.
Installation
yarn add @noddde/kafka kafkajsConfiguration
import { KafkaEventBus } from "@noddde/kafka";
const eventBus = new KafkaEventBus({
brokers: ["localhost:9092"],
clientId: "my-service",
groupId: "my-service-group",
topicPrefix: "noddde.", // optional — topics become "noddde.AccountCreated"
sessionTimeout: 60000, // optional — increase for slow handlers
heartbeatInterval: 5000, // optional — must be < sessionTimeout / 3
resilience: { maxAttempts: 11, initialDelayMs: 500, maxRetries: 5 }, // optional — connection + delivery resilience
partitionKeyStrategy: "aggregateId", // optional — default uses aggregateId for per-aggregate ordering
// logger: myCustomLogger, // optional — defaults to NodddeLogger("warn", "noddde:kafka")
});| Option | Type | Required | Default | Description |
|---|---|---|---|---|
brokers | string[] | Yes | — | Kafka broker addresses |
clientId | string | Yes | — | Client identifier |
groupId | string | Yes | — | Consumer group ID. Events fan out across different group IDs |
topicPrefix | string | No | "" | Prefix prepended to event names to form topic names |
sessionTimeout | number | No | 30000 | Consumer session timeout in ms. Increase if handlers are slow |
heartbeatInterval | number | No | 3000 | Consumer heartbeat interval in ms. Must be less than sessionTimeout/3 |
resilience | BrokerResilience | No | maxAttempts=6, initialDelayMs=300, maxDelayMs=30000 | Connection resilience config. maxAttempts maps to kafkajs retries (minus 1), initialDelayMs to initialRetryTime, maxDelayMs to maxRetryTime. maxRetries limits per-message delivery attempts |
partitionKeyStrategy | "aggregateId" | ((event: Event) => string | null) | No | "aggregateId" | Strategy for deriving the Kafka message key. Default uses event.metadata?.aggregateId (stringified) for per-aggregate partition ordering, falling back to null (round-robin). A custom function receives the full event and returns the key string or null |
logger | Logger | No | NodddeLogger("warn", "noddde:kafka") | Framework logger instance from @noddde/core. All internal logging goes through this logger with structured context data |
warmupOnConnect | boolean | No | false | When true, connect() runs a warmup() round-trip before its returned promise resolves, so a single await connect() returns a fully warmed bus (see Cold-start warmup) |
warmupTimeoutMs | number | No | 60000 | Timeout in ms for the warmup() publish/consume round-trip before it rejects |
How It Works
- Publishing: Events are serialized as JSON and sent to a Kafka topic derived from the event name (
${topicPrefix}${event.name}). The message key is determined by thepartitionKeyStrategyoption: by default ("aggregateId"), the key isevent.metadata?.aggregateId(stringified), ensuring per-aggregate partition ordering. If noaggregateIdis present, the key isnull(round-robin). A custom function can be provided to derive the key from the full event. - Subscribing: Handlers registered via
on()receive messages through a Kafka consumer. Handlers registered beforeconnect()are buffered and subscriptions are created when the connection is established. Multiple handlers for the same event are invoked in parallel viaPromise.allSettled()— every handler runs to completion even when some fail, and each failure is logged individually with structured fields (see Handler Error Isolation). - Delivery: At-least-once. The consumer runs with
autoCommit: false— offsets are committed explicitly viaconsumer.commitOffsets()only after all handlers complete successfully. If any handler fails, the first rejection is re-thrown after all siblings have settled (so the offset is not committed and the message is redelivered). After a successful commit, the in-memory delivery count entry for the message is pruned to prevent unbounded memory growth. Consumers must be idempotent — handlers that completed before a sibling failure will re-execute on redelivery. - Poison message protection: Malformed messages that fail JSON deserialization are logged and skipped (offset committed), preventing corrupt data from blocking the partition. If
resilience.maxRetriesis configured, messages that exceed the delivery attempt limit are also skipped with a warning. - Connection resilience: The optional
resilienceconfig (BrokerResiliencefrom@noddde/core) is mapped to kafkajs retry options for automatic reconnection on broker unavailability.maxAttemptsmaps to kafkajsretries(minus 1, since kafkajs counts retries after the first attempt),initialDelayMstoinitialRetryTime, andmaxDelayMstomaxRetryTime. ThesessionTimeoutandheartbeatIntervaloptions tune consumer rebalance behavior for slow handlers. Concurrentconnect()calls are deduplicated via a connection promise mutex to prevent parallel connection attempts. - Logging: All internal logging uses the framework
Loggerinterface from@noddde/core. Pass a customloggerin the config or accept the default (NodddeLogger("warn", "noddde:kafka")). All log calls include structured context data (event name, error details).
Cold-start warmup
A freshly-deployed Kafka cluster is slow on its first end-to-end publish/consume cycle: topic auto-creation, leader election, and ISR sync happen lazily on first use, so the first message can take far longer to be delivered than every message after it. connect() waits for the consumer to begin polling (via kafkajs FETCH_START), but that wait does not cover this broker-side cold start.
warmup() addresses it by running a throwaway publish/consume round-trip on a dedicated internal topic (named from clientId) and resolving only once the round-trip is observed:
await eventBus.connect();
await eventBus.warmup(); // resolves once the broker has served a full round-trip- Must follow
connect(): callingwarmup()beforeconnect()or afterclose()throws the same "not connected" error asdispatch(). - Idempotent: after the first successful call, subsequent calls resolve immediately without repeating the round-trip. Concurrent overlapping calls are deduplicated into a single round-trip via an in-flight promise mutex (mirroring
connect()). - Bounded: rejects with a timeout error if the round-trip does not complete within
warmupTimeoutMs(default60000) rather than hanging indefinitely.
Set warmupOnConnect: true to fold the warmup into connection so a single await connect() returns a fully warmed bus. A warmup failure then propagates through connect()'s returned promise, consistent with how connect() surfaces other connection errors:
const eventBus = new KafkaEventBus({
brokers: ["localhost:9092"],
clientId: "my-service",
groupId: "my-service-group",
warmupOnConnect: true,
warmupTimeoutMs: 60000, // optional — round-trip timeout (default 60000)
});
await eventBus.connect(); // only resolves once the broker is warmWiring with Domain
import { wireDomain } from "@noddde/engine";
import { KafkaEventBus } from "@noddde/kafka";
const domain = await wireDomain(myDomain, {
buses: () => ({
eventBus: new KafkaEventBus({
brokers: ["localhost:9092"],
clientId: "my-service",
groupId: "my-service-group",
}),
}),
});The Domain auto-calls connect() on the event bus during wiring (via Connectable auto-discovery) and close() on shutdown (via Closeable auto-discovery). No manual lifecycle management needed.