Use BEFORE for validation/enrichment, AFTER for side effects. Implement cross-cutting concerns without modifying entity handlers.
req carries in each phasethis.reject() vs req.reject() distinctionCAP's three-phase model maps directly to enterprise patterns: input validation (BEFORE), business logic (ON), and post-processing / side-effects (AFTER). Knowing which hook to use prevents subtle bugs where you validate data that's already been saved, or enrich a response that's already been sent.
Purpose: Validate or enrich the incoming request BEFORE execution.
req.data: The payload as sent by the client (mutable — you can change it here).
req.query: The CQL query built from the OData URL.
Effect: Call req.reject() to abort. Modify req.data to change what's saved.
this.before('CREATE', 'Books', (req) => {
if (!req.data.title) return req.reject(400, 'Title is required')
req.data.createdAt = new Date().toISOString() // enrich before save
})
Purpose: Execute the operation (read from DB, write to DB, call external).
req.data: Same as BEFORE; the generic handler uses it to build SQL.
Return: Your return value becomes the response. Call next() to use generic.
this.on('READ', 'Analytics', async (req) => {
// Completely replace with custom logic
return db.run(`SELECT department, SUM(salary) AS total FROM Employees GROUP BY department`)
})
Purpose: Enrich or log the result AFTER execution.
data: The result data (second argument). Mutable for READ results.
req.data: The original request payload (still available).
Return: Ignored for READ — mutate the array/object in place instead.
this.after('READ', 'Books', (books, req) => {
books.forEach(b => {
b.priceFormatted = `€ ${b.price.toFixed(2)}`
b.inStock = b.stock > 0
})
// No return needed — mutating the array is sufficient
})
// Handlers fire in registration order within each phase
this.before('READ', 'Books', authCheck) // fires first
this.before('READ', 'Books', tenantFilter) // fires second
this.on('READ', 'Books', next => next()) // defers to generic (fires first)
// generic READ handler wired by super.init() // fires second (inside generic slot)
this.after('READ', 'Books', addComputedFields) // fires first
this.after('READ', 'Books', logAccess) // fires second
this.after(['CREATE','UPDATE','DELETE'], '*', (data, req) => {
auditLog.write({
entity: req.target.name,
operation: req.event,
user: req.user.id,
key: req.data?.ID,
at: new Date()
})
})
this.before('*', '*', (req) => {
req.data = req.data || {}
req.data._requestId = crypto.randomUUID() // attach trace ID
})
this.after('READ', 'Products', (products) => {
products.forEach(p => {
delete p._internal_costBasis // strip internal fields
})
})
this.before('DELETE', 'Orders', async (req) => {
const order = await SELECT.one.from('Orders').where({ ID: req.data.ID })
if (order.status === 'SHIPPED') {
return req.reject(409, `Order ${req.data.ID} already shipped — cannot delete`)
// Returning req.reject() stops ALL subsequent handlers (before, on, after)
}
})
this.reject() vs req.reject()req.reject(status, msg) |
this.reject(status, msg) |
|
|---|---|---|
| Returns | A rejected promise (throw-like) | Same |
| Use in | Handler function body | Outside handler (rare) |
| Best practice | Use req.reject() inside handlers |
Avoid outside |
// test/books.test.js
const cds = require('@sap/cds/lib')
describe('Books BEFORE handler', () => {
const { GET, POST } = cds.test('.').in(__dirname + '/..')
it('rejects create without title', async () => {
const res = await POST('/catalog/Books', { stock: 5 })
expect(res.status).toBe(400)
})
})
this.before('UPDATE', 'Books', ...) — reject if req.data.price < 0this.after('READ', 'Books', ...) — add discountedPrice = price * 0.9 to all booksthis.after(['CREATE','UPDATE'], 'Books', ...) — log the operation to consoleconsole.log in each hook and confirm sequencereq.data; AFTER mutates the result arrayreq.reject() inside BEFORE stops the entire chain'*' for cross-cutting concerns