Configuration
Overview
Section titled “Overview”Configuration in v3 is plain JavaScript objects passed to createBus. There is no YAML, no DI container, no “config provider” abstraction — you read environment variables yourself and assemble the shape the bus expects. The trade-off is verbosity for clarity: every dial you can turn is visible in one place.
The problem
Section titled “The problem”A typical service has at least three sources of configuration that need to compose cleanly: transport (where is the broker, what credentials, what prefetch), persistence (database URLs, collection names, index policy), and runtime knobs (default request timeout, poll intervals, logger). When these live across different files with different defaults, you end up with services that boot in dev but explode in production because a single env var was missed, or worse — services that pass health checks but quietly fall back to in-memory storage.
The Node.js port deliberately puts everything in one place: a single BusOptions object you assemble at startup. The cost is some boilerplate; the payoff is that “what is this service configured to do” has a single, readable answer.
The solution
Section titled “The solution”BusOptions has a small, predictable top-level shape:
transport— required. The result ofcreateRabbitMQTransportorrabbitMQWithRegistry.queue: { name }— required. The queue this bus consumes from.registry?— optional sharedMessageTypeRegistry. Pass the same one to the transport for matching wire types.serializer?— defaults to JSON; swap for MessagePack/etc.logger?— defaults toconsoleLogger('info').defaultRequestTimeout?— declared but currently inert: the bus never reads it. Request timeouts must be set per call viaRequestOptions.timeoutMs(sendRequest/sendRequestMultithrowArgumentOutOfRangeErrorwithout a positivetimeoutMs).timeoutPollIntervalMs?— how often the timeout store is swept.aggregatorFlushIntervalMs?— how often aggregators flush ready windows.consumeWrapper?— wraps each consumed-message dispatch (once per message, around the whole dispatch — deserialize, reply routing, every handler, pipeline stages); useful for tracing or per-message scope.
Per-package option blocks live under their owners: RabbitMQTransportOptions for the transport, persistence factory options on the store factories, and pipeline behaviour added via bus.use(...).
A realistic env-driven config looks like this:
import { createBus, consoleLogger } from '@serviceconnect/core';import { rabbitMQWithRegistry } from '@serviceconnect/rabbitmq';import { mongoSagaStore, mongoTimeoutStore } from '@serviceconnect/persistence-mongodb';import { createMessageTypeRegistry } from '@serviceconnect/core';import { MongoClient } from 'mongodb';
const registry = createMessageTypeRegistry();const mongo = await MongoClient.connect(process.env.MONGO_URL!);const db = mongo.db('serviceconnect');
const bus = createBus({ queue: { name: process.env.QUEUE_NAME ?? 'orders' }, transport: rabbitMQWithRegistry( { url: process.env.AMQP_URL ?? 'amqp://localhost', connectionName: process.env.SERVICE_NAME ?? 'orders', consumer: { // 32 here is a chosen fallback, not the framework default (which is 100). prefetch: Number.parseInt(process.env.PREFETCH ?? '32', 10), maxRetries: 5, }, }, registry, ), registry, logger: consoleLogger(process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info'), timeoutPollIntervalMs: 250, aggregatorFlushIntervalMs: 250,});
const sagaStore = mongoSagaStore({ db });const timeoutStore = mongoTimeoutStore({ db });await sagaStore.ensureIndexes();await timeoutStore.ensureIndexes();Note the explicit ensureIndexes() calls — the stores never silently create indexes at first use. They’re idempotent and cheap, so call them as part of “boot the service” rather than relying on “the framework will figure it out”. For the timeout and aggregator stores this actually builds indexes (runAt/claimToken, and the aggregator’s compound index); mongoSagaStore.ensureIndexes() is a no-op because its compound _id is auto-indexed by MongoDB, but calling it keeps the boot sequence uniform.
There is no built-in env-var helper because the rules for parsing are different per app: some teams default to dev-friendly values, some refuse to start without explicit values, some pull from a secrets manager. Keep the parsing close to the call site so the contract between env and code is obvious to anyone reading the bootstrap.
Composing transport, queue, and persistence
Section titled “Composing transport, queue, and persistence”The three blocks compose orthogonally. transport controls the wire and broker behaviour (URL, prefetch, retry/error queues, audit). queue.name decides which queue this process consumes. Persistence stores are constructed independently — they aren’t part of BusOptions because they are used by the application code that registers process managers and aggregators, not by the bus directly. That separation is deliberate: it means a sagaless service has no Mongo dependency, and a Mongo-backed service is explicit about it.
Logging
Section titled “Logging”The default logger writes to stdout in a structured shape. Replace it for any project that ships logs to a central system:
import type { Logger } from '@serviceconnect/core';
const pinoLogger: Logger = { trace: (msg, fields) => pino.trace(fields, msg), debug: (msg, fields) => pino.debug(fields, msg), info: (msg, fields) => pino.info(fields, msg), warn: (msg, fields) => pino.warn(fields, msg), error: (msg, fields) => pino.error(fields, msg), fatal: (msg, fields) => pino.fatal(fields, msg),};
const bus = createBus({ // ... logger: pinoLogger,});The Logger interface is small on purpose. Anything that implements those six methods (trace, debug, info, warn, error, fatal) works — Pino, Bunyan, Winston, or a hand-rolled wrapper that adds a correlation-id field. Only child is optional.