Primary reference: https://cap.cloud.sap/docs/node.js/fiori
Fiori Draft overview: https://cap.cloud.sap/docs/guides/fiori#draft-support
Enabling draft
service OrderService {
@odata.draft.enabled
entity Orders as projection on db.Orders;
}
One annotation — CAP auto-generates the _drafts shadow table, all OData draft actions (draftEdit, draftActivate, draftDiscard), and wires up the Fiori Elements UI automatically.
Draft lifecycle events
module.exports = class OrderService extends cds.ApplicationService {
async init() {
const { Orders } = this.entities
// NEW — user starts creating a new record
this.before('NEW', Orders.drafts, req => {
req.data.status = 'Draft'
})
// EDIT — user opens an existing active record for editing
this.on('EDIT', Orders, async (req) => {
const { ID } = req.params[0]
const order = await SELECT.one(Orders).where({ ID })
if (order?.status === 'Closed') req.reject(409, 'CANNOT_EDIT_CLOSED')
return next()
})
// SAVE (draft activation) — user clicks Save
this.before('CREATE', Orders, this.validateOnSave) // fires on draft activate
this.before('UPDATE', Orders, this.validateOnSave)
// CANCEL — user discards changes, goes back to active state
this.on('CANCEL', Orders.drafts, req => {
// no-op usually, but you can add side effects
})
// DISCARD — user explicitly deletes the draft
this.on('DISCARD', Orders.drafts, req => { /* cleanup if needed */ })
return super.init()
}
async validateOnSave(req) {
const { title, amount } = req.data
if (!title) req.error({ code: 400, message: 'Title required', target: 'title' })
if (amount <= 0) req.error({ code: 422, message: 'Amount must be positive', target: 'amount' })
req.reject() // throws all accumulated errors if any
}
}
Draft Messages (GA since CAP August 2025)
Persistent Fiori draft messages provide fast inline feedback while the user is editing (on each PATCH, not just on Save):
// package.json — enabled by default since Aug 2025
{
"cds": {
"fiori": {
"draft_messages": true
}
}
}
To run validations on every PATCH (not just on Save):
// Triggers on every field change in the Fiori UI
this.before('PATCH', Orders.drafts, req => {
if (req.data.amount !== undefined && req.data.amount < 0) {
req.error({ code: 422, message: 'Amount must be positive', target: 'amount' })
}
req.reject()
})
Note: Draft Messages require a database schema update. Run
cds deployafter enabling. Not supported on OData V2 UIs or UI5 < 1.135.0.
Querying draft entities
// Read active entities only
const orders = await SELECT.from(Orders)
// Read draft entities
const drafts = await SELECT.from(Orders.drafts)
// Read both (Fiori "All" filter — CAP handles this automatically)
// Don't try to merge manually; let CAP's generic handlers do it
// Check if a draft exists for an active record
const hasDraft = await SELECT.one(Orders.drafts).where({ ID: id })
Programmatic draft operations (Node.js)
// Create a new draft
await srv.new(Orders.drafts, { title: 'New Order', amount: 0 })
// Start editing an active record (creates draft from active)
await srv.edit(Orders, { ID: id })
// Activate (save) a draft
await srv.save(Orders.drafts, { ID: id })
// Discard a draft
await srv.discard(Orders.drafts, { ID: id })
Calculated elements in drafts (GA since CAP Node.js 9.x)
entity Orders : cuid, managed {
amount : Decimal(9,2);
taxRate : Decimal(4,2);
grossAmount = amount * (1 + taxRate) : Decimal(9,2); // calculated element
}
Calculated elements are now properly evaluated when reading from the _drafts table — no need for virtual elements + custom code.
Lean Draft (simplified mode)
For simpler use cases without full draft lifecycle:
service OrderService {
@odata.draft.enabled: { mode: 'exclusive' }
entity Orders as projection on db.Orders;
}
Bypass Drafts by Default (CDS 10+)
Since CDS 10, direct access to active entities bypasses draft choreography by default. Non-Fiori clients (e.g. AI agents, REST clients) can now work with active data directly:
// CDS 10: illustrative OData V4 request shapes, not literal calls to run
// These access the ACTIVE entity directly (no draft processing)
GET /odata/v4/Orders(:id) // ✓ reads active directly
PATCH /odata/v4/Orders(:id) // ✓ updates active directly
DELETE /odata/v4/Orders(:id) // ✓ deletes active directly
// These still go through draft choreography:
GET /odata/v4/Orders(:id)/DraftAdministrativeData
PATCH /odata/v4/Orders(:id)/DraftAdministrativeData/InProcessByUserDescription
Impact on handlers: Validation must now account for both paths:
// These are all valid CDS 10 entry points for a draft-enabled entity:
this.before('PATCH', Orders, ...) // Called for both draft AND active PATCH
this.before('DELETE', Orders, ...) // Called for both draft AND active DELETE
this.on('draft', Orders, ...) // Still: draft-specific lifecycle
Opt-out (if you need pre-CDS 10 behavior):
// srv/cat-service.js
cds.fiori.bypass_draft = false
draft_new_action (optional): move draft creation to a named action so that plain POST creates active entities:
{
"cds": { "fiori": { "draft_new_action": true } }
}
With this, POST /Books { ... } creates an active entity; use POST /Books/draftNew to create a draft.
Common mistakes to avoid
- ❌ Registering EDIT handler on
Orders.drafts— EDIT must be on the active entityOrders - ❌ Registering SAVE validation in
before('SAVE', ...)— it doesn't exist; usebefore('CREATE', Orders, ...)andbefore('UPDATE', Orders, ...)(these fire on draft activation) - ❌ Manually merging active + draft results for "All" filter — let CAP do this
- ❌ Forgetting
req.reject()after accumulatingreq.error()calls — errors won't be thrown - ❌ Using
LargeBinaryfields with draft +hdbdriver (causes deadlock) — switch tohana-client - ❌ Not running schema migration after enabling
draft_messages - ❌ Using virtual elements for calculated values in drafts — use CDS calculated elements instead (CAP 9+)