Skip to content

Topology options

Options for every contract-building function. For recipes see define a contract; for the generated API surface see the API reference.

defineContract

defineContract({ publishers, consumers, rpcs, exchanges, queues, bindings })

Every key is optional. A worked example, using all six:

typescript
import {
  defineContract,
  defineEventConsumer,
  defineEventPublisher,
  defineExchange,
  defineMessage,
  defineQueue,
  defineQueueBinding,
  defineRpc,
} from "@amqp-contract/contract";
import { z } from "zod";

const ordersExchange = defineExchange("orders");
const ordersDlx = defineExchange("orders-dlx");
const auditExchange = defineExchange("audit");
const orderMessage = defineMessage(z.object({ orderId: z.string() }));
// `orders-dlx` is topic and neither queue sets a dead-letter routing key, so
// `#` catches whatever key the message arrived with.
const orderQueue = defineQueue("order-processing", { deadLetter: { exchange: ordersDlx } });
const lookupQueue = defineQueue("order-lookup", { deadLetter: { exchange: ordersDlx } });
const ordersDlq = defineQueue("order-processing-dlq");
const auditQueue = defineQueue("order-audit");

const orderCreated = defineEventPublisher(ordersExchange, orderMessage, {
  routingKey: "order.created",
});
const lookupOrder = defineRpc(lookupQueue, {
  request: orderMessage,
  response: defineMessage(z.object({ status: z.string() })),
});

defineContract({
  publishers: { orderCreated },
  consumers: { processOrder: defineEventConsumer(orderCreated, orderQueue) },
  rpcs: { lookupOrder },
  exchanges: { auditExchange },
  queues: { ordersDlq, auditQueue },
  bindings: {
    ordersDlq: defineQueueBinding(ordersDlq, ordersDlx, { routingKey: "#" }),
    auditBinding: defineQueueBinding(auditQueue, auditExchange, { routingKey: "order.#" }),
  },
});
KeyContains
publishersNamed publishers callable via client.publish(name, …)
consumersNamed consumers requiring a handler on the worker
rpcsNamed RPCs, callable via client.call(name, …) and requiring a handler
exchangesStandalone exchanges with no publisher/consumer attached
queuesStandalone queues with no consumer in this service
bindingsStandalone bindings (defineQueueBinding / defineExchangeBinding)

The returned contract exposes exchanges, queues and bindings extracted from the publishers, consumers and RPCs — you rarely declare them directly. The standalone keys exist for topology this service asserts without attaching a publisher or consumer to it: the classic cases are a DLQ bound to the auto-extracted dead-letter exchange, or an audit queue another process drains. Dead-letter exchanges are auto-extracted for standalone queues exactly as for consumer queues (TTL-backoff wait queues are derived at setup time and never appear in the contract). In the output, standalone exchanges and queues are re-keyed by their resource name; binding labels are kept verbatim. See declare standalone topology.

defineExchange

typescript
defineExchange(name, options?);
OptionTypeDefaultNotes
type"topic" | "direct" | "fanout" | "headers""topic"
durablebooleantrue
autoDeletebooleanfalseDeleted when all queues have finished using it
internalbooleanfalseNot publishable by clients; only via exchange-to-exchange bindings
argumentsRecord<string, unknown>Raw AMQP exchange arguments (e.g. alternate-exchange)
TypeRouting
topicRouting-key patterns with * and # wildcards
directExact routing-key match
fanoutAll bound queues; routing key ignored
headersMessage headers; routing key ignored

Routing keys are required for direct and topic, optional and ignored for fanout and headers. This is enforced at compile time.

defineQueue

typescript
defineQueue(name, options?);
OptionTypeDefaultNotes
type"quorum" | "classic""quorum"
durablebooleantrueQuorum queues are always durable; false requires classic
autoDeletebooleanfalseclassic only
exclusivebooleanfalseclassic only
maxPrioritynumberclassic only
deadLetter{ exchange, routingKey? }See below
onPoison"drop"Declares deliberate loss; see below
retryretry config{ mode: "none" }See below
argumentsRecord<string, unknown>Raw AMQP queue arguments

Quorum queues replicate through Raft and cannot be exclusive, auto-deleting, or priority queues. TypeScript rejects those options unless type: "classic".

deadLetter

FieldTypeNotes
exchangeexchange definitionExtracted into the contract automatically
routingKeystringOptional. Omitted, the original routing key is preserved

onPoison

defineContract rejects a consumed queue that has neither a deadLetter nor onPoison: "drop", because such a queue discards every message its handler rejects with no record of the loss. Set onPoison: "drop" when losing them is the intent — a metrics firehose, or a dead-letter queue you consume (which cannot dead-letter to itself).

Declared-but-unconsumed queues are not checked.

arguments

Common raw arguments:

ArgumentEffect
x-message-ttlPer-message TTL in ms; expiry routes to the DLX
x-expiresQueue idle TTL in ms; deletes the queue and its messages, not dead-lettered
x-max-lengthMax messages; overflow routes to the DLX
x-max-length-bytesMax total body bytes; overflow routes to the DLX
x-delivery-limitQuorum-queue redelivery cap (RabbitMQ 4 default: 20)

Retry configuration

typescript
retry: { mode, maxRetries, … }
ModeBehaviour
none (default)No retry. RetryableError is dead-lettered like NonRetryableError.
immediate-requeueRequeued at once, up to maxRetries, then dead-lettered.
ttl-backoffParked in a per-delay wait queue with growing TTL, then routed back.

immediate-requeue

OptionDefault
maxRetries3

Attempt counts come from x-delivery-count on quorum queues (broker-native) and from a worker-maintained x-retry-count on classic queues.

Quorum queues also enforce x-delivery-limit independently of maxRetries.

ttl-backoff

OptionDefaultDescription
maxRetries3Maximum attempts
initialDelayMs1000First delay
maxDelayMs30000Delay cap (applied to the base delay)
backoffMultiplier2Exponential factor
jittertrue±50% randomisation, avoids thundering herd

The base delay is initialDelayMs * backoffMultiplier ^ attempt, capped at maxDelayMs. Each distinct base delay gets its own wait queue, {queueName}-wait-{delayMs}ms, declared by setupAmqpTopology at channel-setup time (derived from the queue's retry config via deriveTtlBackoffInfrastructure — never stored in the contract). The worker publishes the retry copy to the tier queue via the default exchange; the tier dead-letters it back to the main queue when its TTL expires.

RabbitMQ only expires messages at the head of a queue, so per-tier queues are what keep a 60s retry from blocking a later 1s retry. Within a tier every message shares the same base delay: the per-message expiration carries the jittered value and the tier's queue-level x-message-ttl is set to the jitter ceiling (ceil(base * 1.5)) as a backstop, so head-of-line skew within a tier is bounded by the jitter spread — and is zero with jitter: false.

Retried deliveries arrive via the default exchange, so their fields.routingKey is the queue name; the original routing key is preserved in the x-original-routing-key header.

Diagnostic headers

Stamped only on paths that republish the message — classic queues under immediate-requeue, and any queue under ttl-backoff.

HeaderMeaningSet on
x-delivery-countBroker-native attempt countQuorum queues, by RabbitMQ
x-retry-countWorker-managed attempt countRepublish paths
x-last-errorMost recent failure messageRepublish paths
x-first-failure-timestampEpoch ms of first failureRepublish paths
x-original-routing-keyRouting key of the first deliveryRepublish paths

Direct-nack paths add nothing, so those dead-lettered messages arrive exactly as delivered.

defineMessage

typescript
defineMessage(payloadSchema, options?);
OptionTypePurpose
headersStandard SchemaHeader shape, validated on the consumer only
summarystringShort description; flows into AsyncAPI
descriptionstringLong description; flows into AsyncAPI

With no headers schema, a handler's headers is undefined.

Publishers

FunctionSignature
definePublisher(exchange, message, { routingKey })
defineEventPublisher(exchange, message, { routingKey })
defineCommandPublisher(commandConsumer, { bridgeExchange? })

defineEventPublisher adds compile-time routing-key validation and is what event consumers attach to. defineCommandPublisher derives everything from the command consumer.

All three also accept externalConsumers?: boolean. defineContract throws when a publisher's routing key reaches no queue in the contract's binding graph — the broker would confirm those messages and discard them. Set it to true when another service owns the binding. An exchange declaring alternate-exchange is exempt. See troubleshoot.

There is no per-call routing-key override — publish always uses the publisher's key.

Consumers

FunctionSignature
defineConsumer(queue, message)
defineEventConsumer(publisher, queue, { routingKey?, bridgeExchange? })
defineCommandConsumer(queue, exchange, message, { routingKey })

routingKey on defineEventConsumer overrides the binding pattern (for wildcards); the payload type still comes from the publisher.

defineRpc

typescript
defineRpc(queue, { request, response, errors? });
KeyType
requestMessage definition
responseMessage definition
errorsRecord<code, RpcErrorDefinition> — declared business errors

Each errors entry is { data, message? }: data is the Standard Schema validating the error's payload, and the optional message is the default human-readable message used when the handler does not supply one.

Client options

TypedAmqpClient.create({ … }):

OptionTypeDefault
contractcontractrequired
urlsConnectionUrl[]required
connectionOptionsamqp-connection-manager options
defaultPublishOptionsPublishOptions{ persistent: true }
connectTimeoutMsnumber | null30000; null waits forever
loggerLogger
telemetryTelemetryProviderauto-detected
publishInterceptorsPublishInterceptor[]
callInterceptorsCallInterceptor[]

ConnectionUrl is amqp-connection-manager's URL type — a plain amqp:// string, an amqplib Options.Connect object, or { url, connectionOptions }.

PublishOptions is amqplib's Options.Publish plus compression?: "gzip" | "deflate". Properties are flat: persistent, priority, expiration, correlationId, headers, and the rest.

An invalid connectTimeoutMs (NaN, zero, negative, Infinity) surfaces as a defect from create() rather than silently disabling the timeout. Only null disables it.

Worker options

TypedAmqpWorker.create({ … }):

OptionType
contractcontract
handlersone entry per consumer and RPC
urlsConnectionUrl[]
connectionOptionsamqp-connection-manager options
connectTimeoutMsnumber | null (default 30000; null waits forever)
defaultConsumerOptionsConsumerOptions (see below)
middlewareWorkerMiddleware, an array of them, or composeMiddleware(…)
createContext(info) => context
loggerLogger
telemetryTelemetryProvider

A handler entry is either the function or a [function, ConsumerOptions] tuple.

ConsumerOptions is a curated subset of the AMQP consume options: prefetch, priority, arguments, consumerTag, exclusive. noAck and noLocal are deliberately excluded — noAck: true would silently break the ack-exactly-once and retry/DLQ invariants, and noLocal is not supported by RabbitMQ.

The middleware array form composes at runtime like composeMiddleware(…) (first entry outermost) but does not thread stepwise context types — see add middleware.

worker.close(options?) accepts { drainTimeoutMs?: number | null } — how long to wait for in-flight handlers before tearing the channel down (default DEFAULT_DRAIN_TIMEOUT_MS, 30 000 ms; null waits forever). See consume messages.

Logger, TelemetryProvider and TechnicalError are re-exported by both @amqp-contract/client and @amqp-contract/worker, so naming an option type or matching a defect cause never forces a direct dependency on @amqp-contract/core.

Routing-key validation types

typescript
import type { BindingPattern, MatchingRoutingKey, RoutingKey } from "@amqp-contract/contract";
TypePurpose
RoutingKey<K>K if it is a valid key, else never
BindingPattern<P>P if it is a valid pattern, else never
MatchingRoutingKey<P, K>K if it matches P, never if it provably doesn't

Keys are dot-separated segments of alphanumerics, hyphens and underscores. * matches one segment, # matches zero or more, and both are valid only in patterns.

MatchingRoutingKey always enforces each side's own validity — RoutingKey<K>, BindingPattern<P> — since that's decidable from one side alone, regardless of the other side's shape. It can only decide the match between a valid pattern and a valid key when both P and K are fully resolved string literals at compile time; a plain string, a template-literal type, a union containing either, or a branded string type, among others, skips the match and resolves to K unchecked rather than guessing.

TypeScript's recursion limit means very long keys fall back to string. Compile-time checking only; runtime behaviour is unaffected.

Schema inference types

typescript
import type { InferSchemaInput, InferSchemaOutput } from "@amqp-contract/contract";

type OrderCreated = InferSchemaInput<typeof contract.publishers.orderCreated.message.payload>;

InferSchemaInput<TSchema> and InferSchemaOutput<TSchema> extract the input and output types of any Standard Schema — what a publisher accepts versus what a handler receives after parsing.

Where next

Released under the MIT License.