Skip to content

Introduction

Hoppity is a contract-driven RabbitMQ topology builder for Node.js microservices, built on top of Rascal. You declare domain contracts (events, commands, RPC) using Zod schemas, register handlers, and Hoppity derives all the Rascal topology automatically — exchanges, queues, bindings, publications, and subscriptions.

Rascal gives you a powerful, configuration-driven way to manage RabbitMQ topologies. But as your microservice estate grows, you end up with:

  • Repeated boilerplate across services (the same exchange patterns, the same retry configurations)
  • Topology config objects that balloon into hundreds of lines
  • String-literal coupling between services — rename a queue and watch everything break at runtime instead of compile time
  • No clean way to share message schemas between producers and consumers

Hoppity introduces domain contracts as the single source of truth. You define what your service handles and what it sends. The topology is derived mechanically from those declarations.

import { z } from "zod";
import hoppity, { defineDomain, onEvent, onCommand, onRpc } from "@apogeelabs/hoppity";

// Define the domain — shared between services
const OrdersDomain = defineDomain("orders", {
    events: {
        orderCreated: z.object({ orderId: z.string(), total: z.number() }),
    },
    commands: {
        cancelOrder: z.object({ orderId: z.string() }),
    },
    rpc: {
        getOrderSummary: {
            request: z.object({ orderId: z.string() }),
            response: z.object({ orderId: z.string(), status: z.string() }),
        },
    },
});

// Build the service — handlers and publishes ARE the topology
const broker = await hoppity
    .service("order-service", {
        connection: { url: "amqp://localhost" },
        handlers: [
            onCommand(OrdersDomain.commands.cancelOrder, async ({ orderId }, { broker }) => {
                // handle the command...
            }),
            onRpc(OrdersDomain.rpc.getOrderSummary, async ({ orderId }) => {
                return { orderId, status: "active" };
            }),
        ],
        publishes: [OrdersDomain.events.orderCreated],
        logger: myLogger, // optional — defaults to ConsoleLogger
    })
    .build();

// Typed outbound methods on the broker
await broker.publishEvent(OrdersDomain.events.orderCreated, { orderId: "ord-1", total: 42.0 });
const summary = await broker.request(OrdersDomain.rpc.getOrderSummary, { orderId: "ord-1" });

No topology files. No manual exchange/queue/binding declarations. The contracts carry the naming conventions, and deriveTopology generates all the Rascal config.

PackagePurpose
@apogeelabs/hoppityCore — contracts, handlers, topology derivation, builder, broker wiring, RPC, delayed delivery
@apogeelabs/hoppity-open-telemetryOpenTelemetry tracing and metrics interceptors

When you call .build(), seven phases execute in order:

  1. Topology derivationderiveTopology() generates all exchanges, queues, bindings, publications, and subscriptions from the handler declarations and publish contracts.

  2. Topology mergemergeTopology() combines any optional raw BrokerConfig with the derived topology. Raw config is the base; derived topology layers on top.

  3. Middleware pipeline — Each middleware runs sequentially, receiving the complete merged topology and shared MiddlewareContext. Returns modified topology and an optional onBrokerCreated callback.

  4. Broker creationBrokerAsPromised.create(finalTopology) via Rascal.

  5. Handler wiring — Event, command, and RPC handlers are subscribed to their derived queues.

  6. Outbound wiringpublishEvent, sendCommand, request, and cancelRequest are attached to the broker.

  7. Callback execution — Each middleware’s onBrokerCreated callback runs against the fully-wired broker. If any callback throws, the broker is shut down before the error propagates.