Concepts
Contract-Driven Topology
Section titled “Contract-Driven Topology”Hoppity’s core idea: handlers and publish declarations ARE the topology. You declare what your service handles and what it sends. deriveTopology generates all Rascal config automatically — exchanges, queues, bindings, publications, and subscriptions.
No topology files. No string-literal exchange/queue names scattered across services. The contracts carry all the naming conventions, and the topology is derived mechanically.
Domain Contracts
Section titled “Domain Contracts”A domain groups related events, commands, and RPC operations with Zod schemas:
Each contract object carries:
- Schema — Zod type for runtime validation and compile-time inference
- Exchange name —
{domain}for events/commands,{domain}_rpcfor RPC - Routing key —
{domain}.{type}.{snake_name}(e.g.,orders.event.order_created) - Publication/subscription names — derived mechanically from domain, type, and operation name
Contracts are shared between services. Both producer and consumer import the same contract objects.
Handlers
Section titled “Handlers”Handlers declare what your service processes. Each is bound to a contract and registered in ServiceConfig.handlers:
All handlers accept optional HandlerOptions for queue type, redelivery limits, and dead-letter configuration:
Seven Build Phases
Section titled “Seven Build Phases”When you call .build(), seven phases execute in order:
1. Topology Derivation
Section titled “1. Topology Derivation”deriveTopology() generates a complete Rascal BrokerConfig from handler declarations and publish contracts. Every handler produces a queue, binding, and subscription. Every publish contract produces a publication (and the exchange, if not already declared by a handler).
2. Topology Merge
Section titled “2. Topology Merge”mergeTopology(rawTopology, derived) combines the optional raw topology from ServiceConfig (as base) with the derived config. This is the escape hatch — use it for one-off infrastructure that can’t be derived (DLX exchanges, shovel config, etc.).
3. Middleware Pipeline (synchronous)
Section titled “3. Middleware Pipeline (synchronous)”Each middleware function runs sequentially. It receives:
topology— the currentBrokerConfig, after derivation and mergecontext— a sharedMiddlewareContextwith logger, mutable data store, and middleware name tracking
Each returns a MiddlewareResult:
Middleware functions are synchronous. Async work goes in onBrokerCreated.
4. Broker Creation
Section titled “4. Broker Creation”The final accumulated topology is passed to Rascal’s BrokerAsPromised.create(). This is where the AMQP connection is established and all exchanges, queues, and bindings are declared in RabbitMQ.
5. Handler Wiring
Section titled “5. Handler Wiring”wireHandlers() subscribes event and command handlers to their queues. wireRpcHandlers() subscribes RPC responders. Auto-ack on success, nack without requeue on error.
6. Outbound Wiring
Section titled “6. Outbound Wiring”wireOutbound() attaches publishEvent and sendCommand to the broker. If any RPC handlers or callers exist, the reply queue subscription is set up and request / cancelRequest are attached. broker.shutdown() is wrapped to drain pending RPC requests.
7. Callback Execution (async, sequential)
Section titled “7. Callback Execution (async, sequential)”Each onBrokerCreated callback runs in the order its middleware was registered. If any callback throws, the broker is shut down before the error propagates.
MiddlewareContext
Section titled “MiddlewareContext”The shared context flows through the entire pipeline:
context.data— Any middleware can read/write here. Useful for passing configuration between middleware.context.logger— All middleware should use this for logging. Set vialoggerinServiceConfig— active before any middleware runs.context.serviceName— The service name, populated automatically by the builder.
Middleware
Section titled “Middleware”Middleware is for cross-cutting concerns only — things that apply across the service but aren’t specific to any one handler or contract. Observability, custom topology augmentation.
Handlers, subscriptions, RPC, and typed broker methods are all built into core. You do not need middleware for any of those.
Custom Logger
Section titled “Custom Logger”Pass your logger directly in ServiceConfig — it’s the simplest approach and has no ordering concerns:
Writing Custom Middleware
Section titled “Writing Custom Middleware”A middleware is a function (or a factory that returns one) matching the MiddlewareFunction signature:
Key rules:
- Clone before modifying — Use
structuredClone(topology)to avoid mutating the input - Synchronous body — The middleware function itself must be synchronous. Async work goes in
onBrokerCreated - Return topology — Always return
{ topology }even if you didn’t modify it - Use
context.logger— Notconsole.log. Respect the pipeline’s logger.
Interceptors
Section titled “Interceptors”Interceptors wrap handler and publish execution for per-message cross-cutting concerns — tracing, metrics, header injection. They complement middleware: middleware runs once during setup, interceptors run for every message.
For composition mechanics, metadata types, RPC support, and the full comparison with middleware, see the Interceptors guide.
Naming Conventions
Section titled “Naming Conventions”Topology artifact names are derived mechanically from domain contracts. You don’t set them — the contracts carry them.
| Artifact | Pattern | Example |
|---|---|---|
| Exchange (event/command) | {domain} | orders |
| Exchange (rpc) | {domain}_rpc | orders_rpc |
| Routing key (event) | {domain}.event.{snake_name} | orders.event.order_created |
| Routing key (command) | {domain}.command.{snake_name} | orders.command.cancel_order |
| Routing key (rpc) | {domain}.rpc.{snake_name} | orders.rpc.create_order |
| Queue | {service}_{domain}_{type}_{snake_name} | catalog-service_orders_event_order_created |
| Publication name | {domain}_{type}_{snake_name} | orders_event_order_created |
| Subscription name | {domain}_{type}_{snake_name} | orders_event_order_created |
| Reply queue | {service}_{instanceId}_reply | order-service_abc123_reply |
camelCase operation names are converted to snake_case. Acronyms collapse to a single segment: getHTTPResponse becomes get_http_response.
The Escape Hatch
Section titled “The Escape Hatch”For services not using contracts, or for one-off infrastructure that can’t be derived, pass raw Rascal BrokerConfig directly:
You can combine raw topology with derived topology. The raw config is the base; derived topology layers on top: