27 · Custom Events

Emit and subscribe to in-process events using this.emit(). Decouple handler logic with event-driven communication.

Episode 27 — Emitting & Handling CDS Events

What you'll learn

Why this matters

Event-driven architecture decouples services: an order service doesn't need to know about the notification service, the audit service, or the loyalty service. CDS events let you build this within a single CAP deployment, cleanly and without message brokers for in-process cases.

Events in CDS

// srv/orders-service.cds
service OrdersService {
  entity Orders as projection on my.Orders;

  event OrderSubmitted {
    ID     : UUID;
    amount : Decimal;
    buyer  : String;
  }

  event OrderCancelled {
    ID     : UUID;
    reason : String;
  }
}

Events are declared in the service that emits them. Other services subscribe to them.

Emitting an event

// srv/orders-service.js
this.on('submitOrder', async (req) => {
  const { orderId } = req.data

  await UPDATE('Orders').set({ status: 'SUBMITTED' }).where({ ID: orderId })
  const order = await SELECT.one.from('Orders').where({ ID: orderId })

  // Emit the event — all in-process subscribers receive it
  await this.emit('OrderSubmitted', {
    ID:     order.ID,
    amount: order.total,
    buyer:  req.user.id
  })

  return order
})

Subscribing in the same service

this.on('OrderSubmitted', async (msg) => {
  console.log('Order submitted:', msg.data.ID, 'by', msg.data.buyer)
  // msg.data carries the event payload
  // This runs in the same transaction if emitted from within a handler
})

Cross-service subscription

// srv/notification-service.js
const cds = require('@sap/cds')

module.exports = class NotificationService extends cds.ApplicationService {
  async init () {
    // Subscribe to events from another service
    const orders = await cds.connect.to('OrdersService')
    orders.on('OrderSubmitted', async (msg) => {
      await this.sendEmail({
        to:      msg.data.buyer,
        subject: `Order ${msg.data.ID} confirmed`,
        body:    `Total: €${msg.data.amount}`
      })
    })

    await super.init()
  }
}

Event data access in handlers

this.on('OrderSubmitted', async (msg) => {
  const { ID, amount, buyer } = msg.data    // typed payload from CDS event def
  const tenant  = msg.tenant                // for multitenant apps
  const headers = msg.headers               // optional headers
})

Transactional vs fire-and-forget

By default, in-process events are synchronous within the request transaction — the event handler runs before the outer transaction commits.

// Both the UPDATE and the audit log are in the same DB transaction
await UPDATE('Orders').set({ status: 'SUBMITTED' }).where({ ID })
await this.emit('OrderSubmitted', { ID, amount })   // audit runs here, same tx

For fire-and-forget (run after commit):

cds.context = cds.context || {}
cds.spawn({ tenant: req.tenant }, async () => {
  await this.emit('OrderSubmitted', { ID, amount })
})

Event-driven vs direct service call

In-process event Direct service call
Emitter doesn't know subscribers Caller explicitly invokes callee
Loose coupling Tight coupling
Multiple subscribers possible One callee
Order of execution not guaranteed Sequential
Use for: notifications, audit, side-effects Use for: business logic that must complete

Design pattern: domain events

// After any business operation, emit a domain event
this.after('CREATE', 'Orders',  (order, req) => this.emit('OrderCreated',   { ...order, actor: req.user.id }))
this.after('UPDATE', 'Orders',  (order, req) => this.emit('OrderUpdated',   { ...order, actor: req.user.id }))
this.on('cancelOrder', 'Orders', async (req) => {
  // ... cancel logic ...
  await this.emit('OrderCancelled', { ID, reason: req.data.reason })
})

Hands-on exercise

  1. Declare event BookPurchased { bookId: UUID; quantity: Integer; buyer: String; } in your service
  2. Emit it from an purchaseBook action after updating stock
  3. Add a subscriber that logs the event to a PurchaseLog entity
  4. Add a cross-service subscriber in a separate AnalyticsService that counts purchases

Checkpoint ✓

Sign in for progress tracking & Q&A →