Core Building Blocks / Subscription

What is a Subscription?

Understand the subscription lifecycle, explicit outcomes, filtering, and bridge advice.

A subscription is a fire-and-forget event listener. When a message flows through the event bridge, subscriptions filter it, run guards, invoke a handler, and decide what to do with the event. The original sender does not wait for the subscription to finish — they have already moved on.

This makes subscriptions ideal for:

  • Cross-service reactions — when Service A emits an event, Service B reacts
  • Side effects — send an email, update a cache, log analytics
  • Event-driven workflows — trigger follow-up commands when an order is placed
  • Async processing — handle long-running tasks without blocking the caller
Not for request/response

If the caller needs a response, use a command. Subscriptions can invoke commands, but they never return data to the original sender.

Subscription lifecycle

sequenceDiagram
    participant EB as Event Bridge
    participant S as Subscription
    participant H as Handler
    EB->>S: Event message
    S->>S: Filter match?
    S->>S: Before guards (parallel)
    S->>H: Invoke handler
    H-->>S: { status: 'ack' }
    S->>EB: Acknowledge
  1. Event arrives — the event bridge delivers a message
  2. Filter check — PURISTA checks if the subscription’s filters match
  3. Before guards — all before guards run in parallel; any failure cancels the handler
  4. Handler execution — the subscription function runs
  5. Outcome — the handler returns an explicit outcome dictating what happens to the message

Explicit outcomes

The handler must return an explicit outcome:

StatusMeaning
ackMessage acknowledged — remove from queue
retrySchedule a retry — message stays in queue
deadLetterSend to dead-letter queue
dropSilently drop the message
stop-consumerStop the consumer (use with caution)
// Return an outcome object
return { status: 'ack' }

// Throw a control error
throw new SubscriptionConsumerControlError('Temporary failure', { status: 'retry' })
Always return an outcome

Failing to return an outcome or throw a control error leads to undefined behavior. The bridge may redeliver, drop, or dead-letter depending on its configuration.

Filtering

You decide which events the subscription receives:

import { z } from 'zod'
import { analyticsV1ServiceBuilder } from './analyticsService.js'

const orderCreatedSubscription = analyticsV1ServiceBuilder
  .getSubscriptionBuilder('onOrderCreated', 'When an order is created, update analytics')
  // Listen to a specific event name from a specific service
  .subscribeToEvent('OrderService', '1', 'orderCreated')
  // Filter by sender
  .filterSentFrom('OrderService', '1')
  // Filter by receiver
  .filterReceivedBy('AnalyticsService', '1')
  // Filter by message type
  .filterForMessageType('event')
  // Filter by principal
  .filterPrincipalId('user-123')
  // Filter by tenant
  .filterTenantId('tenant-456')
  .addPayloadSchema(z.object({ orderId: z.string() }))
  .setSubscriptionFunction(async function (context, payload, parameter) {
    // ...
    return { status: 'ack' }
  })

Filters are additive — all specified filters must match for the event to be delivered.

Bridge advice

The builder lets you give the event bridge hints about how to handle this subscription:

analyticsV1ServiceBuilder
  .getSubscriptionBuilder('onOrderCreated', 'When an order is created, update analytics')
  .subscribeToEvent('OrderService', '1', 'orderCreated')
  // Durable subscription survives consumer restarts
  .adviceDurable(true)
  // Automatically acknowledge after handler success
  .adviceAutoacknowledgeMessage(true)
  // Failure handling: 'strict' (default) vs 'best-effort'
  .adviceConsumerFailureHandling({ mode: 'best-effort' })
  // Every instance receives the message (broadcast)
  .receiveMessageOnEveryInstance(true)
Advice, not commands

Bridge advice is a hint. The actual behavior depends on the event bridge implementation. For example, .adviceDurable(true) works with NATS but may be ignored by an in-memory bridge.

Can invoke commands

Subscribers can call commands using canInvoke:

import { z } from 'zod'
import { notificationV1ServiceBuilder } from './notificationService.js'

const subscription = notificationV1ServiceBuilder
  .getSubscriptionBuilder('onOrderCreated', 'When an order is created, notify shipping')
  .subscribeToEvent('OrderService', '1', 'orderCreated')
  .addPayloadSchema(z.object({ orderId: z.string() }))
  .canInvoke('ShippingService', '1', 'createShipment', {
    payloadSchema: z.object({ orderId: z.string() }),
  })
  .setSubscriptionFunction(async function (context, payload, parameter) {
    await context.invoke(
      { serviceName: 'ShippingService', serviceVersion: '1', serviceTarget: 'createShipment' },
      { orderId: payload.orderId },
    )
    return { status: 'ack' }
  })

Before guards run in parallel

Subscription before guards run in parallel with Promise.all, the same as command guards. Any guard that throws cancels the handler.

No afterGuards on subscriptions

After guards on subscriptions are handled by the Service runtime. You define them on the builder, but the service is responsible for orchestrating them after the handler completes.