CAP provides four ways to define custom operations beyond standard CRUD.
| Type | HTTP | URL pattern | Use case |
|---|---|---|---|
| Unbound action | POST |
/submitOrder |
Write operation, not tied to a specific record |
| Bound action | POST |
/Books(key)/publish |
Write operation on a specific record |
| Unbound function | GET |
/getStats() |
Read-only query, not tied to a specific record |
| Bound function | GET |
/Books(key)/getDiscountPrice() |
Read-only query on a specific record |
Two rules:
action= write operation (POST) — modifies datafunction= read-only (GET) — never modifies databound= URL contains the record keyunbound= called directly without a key
my-actions/
├── db/
│ ├── schema.cds
│ └── data/
│ └── my.actions-Books.csv
├── srv/
│ ├── cat-service.cds ← declarations
│ └── cat-service.js ← implementations
└── package.json
namespace my.actions;
using { cuid, managed } from '@sap/cds/common';
entity Books : cuid, managed {
title : String(200);
price : Decimal(9, 2);
stock : Integer default 0;
published : Boolean default false;
discount : Decimal(4, 2) default 0;
}
entity Orders : cuid {
book : Association to Books;
amount : Integer;
total : Decimal(9, 2);
}ID,title,price,stock,published,discount
b0000001-0000-0000-0000-000000000001,Clean Code,38.00,10,false,0
b0000001-0000-0000-0000-000000000002,The Pragmatic Programmer,45.00,5,false,0
b0000001-0000-0000-0000-000000000003,Domain-Driven Design,50.00,0,true,10
using my.actions as db from '../db/schema';
service CatalogService {
// Bound actions and functions go inside the same actions {} block
// Do NOT declare entity Books twice — actions{} and functions{} cannot be separate blocks
entity Books as projection on db.Books
actions {
// Bound action: POST /Books(key)/publish
action publish() returns { message : String; };
// Bound action: POST /Books(key)/applyDiscount
action applyDiscount(percent : Decimal(4,2)) returns { newPrice : Decimal(9,2); };
// Bound function: GET /Books(key)/getDiscountPrice()
function getDiscountPrice() returns {
originalPrice : Decimal(9,2);
discountPrice : Decimal(9,2);
discountAmount : Decimal(9,2);
};
};
entity Orders as projection on db.Orders;
// Unbound action: POST /submitOrder
action submitOrder(
bookID : UUID,
amount : Integer
) returns {
orderID : UUID;
message : String;
total : Decimal(9,2);
};
// Unbound function: GET /getStats()
function getStats() returns {
totalBooks : Integer;
totalOrders : Integer;
outOfStock : Integer;
};
}const cds = require('@sap/cds')
module.exports = class CatalogService extends cds.ApplicationService {
async init() {
const { Books, Orders } = this.entities
// ── Unbound Action ──────────────────────────────────────────
// this.on('actionName', handler)
// Parameters come from req.data
this.on('submitOrder', async req => {
const { bookID, amount } = req.data
const book = await SELECT.one.from(Books).where({ ID: bookID })
if (!book) return req.error(404, 'Book not found')
if (book.stock == null) return req.error(500, 'Stock data is invalid')
if (book.stock < amount) return req.error(409, `Insufficient stock, available: ${book.stock}`)
const unitPrice = book.discount > 0
? +(book.price * (1 - book.discount / 100)).toFixed(2)
: book.price
const total = +(unitPrice * amount).toFixed(2)
await UPDATE(Books).set({ stock: book.stock - amount }).where({ ID: bookID })
const orderID = cds.utils.uuid()
await INSERT.into(Orders).entries({ ID: orderID, book_ID: bookID, amount, total })
return { orderID, message: `Order placed for "${book.title}" x${amount}`, total }
})
// ── Unbound Function ────────────────────────────────────────
this.on('getStats', async req => {
const [bookCount] = await SELECT`count(*) as cnt`.from(Books)
const [orderCount] = await SELECT`count(*) as cnt`.from(Orders)
const [outOfStock] = await SELECT`count(*) as cnt`.from(Books).where({ stock: 0 })
return {
totalBooks: bookCount.cnt,
totalOrders: orderCount.cnt,
outOfStock: outOfStock.cnt
}
})
// ── Bound Action ────────────────────────────────────────────
// this.on('actionName', Entity, handler)
// The key of the bound record comes from req.params[0]
this.on('publish', Books, async req => {
const id = req.params[0].ID
const book = await SELECT.one.from(Books).where({ ID: id })
if (!book) return req.error(404, 'Book not found')
if (book.published) return req.error(409, 'Book is already published')
await UPDATE(Books).set({ published: true }).where({ ID: id })
return { message: `"${book.title}" has been published` }
})
this.on('applyDiscount', Books, async req => {
const id = req.params[0].ID
const { percent } = req.data // bound action parameters from req.data
if (percent <= 0 || percent >= 100) {
return req.error(400, 'Discount must be between 1 and 99')
}
const book = await SELECT.one.from(Books).where({ ID: id })
if (!book) return req.error(404, 'Book not found')
await UPDATE(Books).set({ discount: percent }).where({ ID: id })
const newPrice = +(book.price * (1 - percent / 100)).toFixed(2)
return { newPrice }
})
// ── Bound Function ──────────────────────────────────────────
this.on('getDiscountPrice', Books, async req => {
const id = req.params[0].ID
const book = await SELECT.one.from(Books).where({ ID: id })
if (!book) return req.error(404, 'Book not found')
const discountAmount = +(book.price * book.discount / 100).toFixed(2)
const discountPrice = +(book.price - discountAmount).toFixed(2)
return { originalPrice: book.price, discountPrice, discountAmount }
})
return super.init()
}
}cds watchPOST /odata/v4/catalog/submitOrder
Content-Type: application/json
{
"bookID": "b0000001-0000-0000-0000-000000000001",
"amount": 2
}GET /odata/v4/catalog/getStats()POST /odata/v4/catalog/Books(ID=b0000001-0000-0000-0000-000000000001)/publish
Content-Type: application/json
{}POST /odata/v4/catalog/Books(ID=b0000001-0000-0000-0000-000000000002)/applyDiscount
Content-Type: application/json
{ "percent": 20 }GET /odata/v4/catalog/Books(ID=b0000001-0000-0000-0000-000000000002)/getDiscountPrice()POST /odata/v4/catalog/submitOrder
Content-Type: application/json
{
"bookID": "b0000001-0000-0000-0000-000000000001",
"amount": 999
}
# → 409 Insufficient stock| Unbound | Bound | |
|---|---|---|
| CDS declaration location | Top level inside service {} |
Inside entity Books actions {} |
| URL format | /submitOrder |
/Books(key)/publish |
| Record key in JS | req.data (passed as parameter) |
req.params[0].ID |
| Action parameters in JS | req.data |
req.data |
| Typical use case | Global operations, batch processing | Operations on a single specific record |
actions {} and functions {} cannot be two separate blocks on the same entity
// ❌ Causes "Duplicate definition" error
entity Books as projection on db.Books actions { ... };
entity Books functions { ... };
// ✅ Put everything in one actions {} block
entity Books as projection on db.Books
actions {
action publish() returns { ... };
function getDiscountPrice() returns { ... };
};Bound handler signature requires the entity as second argument
// ❌ Wrong — treated as unbound
this.on('publish', async req => { ... })
// ✅ Correct — entity as second argument makes it bound
this.on('publish', Books, async req => { ... })Bound record key comes from req.params, not req.data
// ❌ Wrong
const id = req.data.ID
// ✅ Correct
const id = req.params[0].IDFunctions must be called with () in the URL
# ❌ 404 Not Found
GET /odata/v4/catalog/getStats
# ✅ Correct
GET /odata/v4/catalog/getStats()
GET /odata/v4/catalog/Books(key)/getDiscountPrice()