SAP Cloud Application Programming Model (CAP): Complete Development Guide
The SAP Cloud Application Programming Model (CAP) is a framework of languages, libraries, and tools for building enterprise-grade services and applications. This comprehensive guide covers everything you need to build production-ready applications with CAP.
What is SAP CAP?
CAP is SAP’s opinionated framework for building cloud-native applications that:
- Accelerates development with best practices baked in
- Provides out-of-the-box features (authentication, authorization, caching)
- Offers a declarative approach using Core Data Services (CDS)
- Supports both Node.js and Java runtimes
- Integrates seamlessly with SAP HANA and other SAP services
Why Choose CAP?
Traditional Development vs CAP Development:
// Traditional approach (100+ lines)app.get('/api/books', authenticate, authorize, async (req, res) => { const connection = await pool.getConnection(); const query = `SELECT * FROM books WHERE deleted = 0`; const results = await connection.execute(query); // Handle pagination, filtering, sorting... // Handle errors... // Handle caching... // Handle audit logging... res.json(results);});
// CAP approach (declarative)service CatalogService { entity Books as projection on my.Books;}// Everything else is handled automatically!Core Concepts
1. Core Data Services (CDS)
CDS is a universal modeling language for defining data models and service APIs:
// schema.cds - Domain modelnamespace bookshop;
entity Books { key ID : UUID; title : String(111); descr : String(1111); author : Association to Authors; stock : Integer; price : Decimal(9,2); currency : Currency;}
entity Authors { key ID : UUID; name : String(111); books : Association to many Books on books.author = $self;}
// Common types and aspectstype Currency : String(3);
aspect managed { createdAt : Timestamp @cds.on.insert: $now; createdBy : String @cds.on.insert: $user; modifiedAt : Timestamp @cds.on.insert: $now @cds.on.update: $now; modifiedBy : String @cds.on.insert: $user @cds.on.update: $user;}2. Service Definitions
Define OData services that expose your entities:
using { bookshop } from '../db/schema';
service CatalogService @(path:'/browse') {
@readonly entity Books as projection on bookshop.Books { *, author.name as authorName } excluding { createdBy, modifiedBy };
@requires: 'authenticated-user' entity Authors as projection on bookshop.Authors;
@requires: 'Admin' action submitOrder (book: Books:ID, quantity: Integer);
function getRecommendations(genre: String) returns array of Books;}3. Custom Event Handlers
Implement business logic in Node.js or Java:
// srv/cat-service.js - Node.js implementationconst cds = require('@sap/cds');
module.exports = cds.service.impl(async function() { const { Books, Authors } = this.entities;
// Before READ - add computed values this.after('READ', 'Books', (books) => { books.forEach(book => { if (book.stock > 100) book.availability = 'In Stock'; else if (book.stock > 0) book.availability = 'Low Stock'; else book.availability = 'Out of Stock'; }); });
// Before CREATE - validation this.before('CREATE', 'Books', (req) => { const { price, stock } = req.data; if (price < 0) req.error(400, 'Price must be positive'); if (stock < 0) req.error(400, 'Stock cannot be negative'); });
// Custom action this.on('submitOrder', async (req) => { const { book, quantity } = req.data;
// Check stock const bookRecord = await SELECT.one.from(Books).where({ ID: book }); if (!bookRecord) return req.error(404, 'Book not found'); if (bookRecord.stock < quantity) { return req.error(409, 'Insufficient stock'); }
// Update stock await UPDATE(Books) .set({ stock: bookRecord.stock - quantity }) .where({ ID: book });
// Emit event for other services await this.emit('OrderPlaced', { book: book, quantity: quantity, customer: req.user.id });
return { success: true, orderNumber: generateOrderNumber() }; });
// Custom function this.on('getRecommendations', async (req) => { const { genre } = req.data;
// Use CDS Query Language (CQL) const recommendations = await SELECT.from(Books) .where({ genre: genre }) .orderBy('rating desc') .limit(10);
return recommendations; });
// Integration with external service const S4Sales = await cds.connect.to('S4SalesService');
this.after('READ', 'Books', async (books, req) => { // Enhance with S/4HANA data for (const book of books) { if (book.ISBN) { const salesData = await S4Sales.run( SELECT.from('ProductSalesData') .where({ ProductID: book.ISBN }) ); book.totalSales = salesData?.TotalSales || 0; } } });});
function generateOrderNumber() { return `ORD-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;}Java Implementation
package com.bookshop.handlers;
import com.sap.cds.services.handler.EventHandler;import com.sap.cds.services.handler.annotations.*;import com.sap.cds.services.cds.CdsService;import com.sap.cds.ql.Select;import com.sap.cds.ql.Update;import com.sap.cds.ql.cqn.CqnSelect;import com.sap.cds.ql.cqn.CqnUpdate;import com.sap.cds.Result;import org.springframework.stereotype.Component;
import cds.gen.catalogservice.*;import cds.gen.bookshop.*;
import java.math.BigDecimal;import java.util.List;
@Component@ServiceName(CatalogService_.CDS_NAME)public class CatalogServiceHandler implements EventHandler {
@Before(event = CdsService.EVENT_CREATE, entity = Books_.CDS_NAME) public void validateBookCreate(CreateContext context, Books book) { if (book.getPrice() != null && book.getPrice().compareTo(BigDecimal.ZERO) < 0) { context.getMessages().error("Price must be positive"); context.setCompleted(); } }
@After(event = CdsService.EVENT_READ, entity = Books_.CDS_NAME) public void calculateAvailability(List<Books> books) { books.forEach(book -> { Integer stock = book.getStock(); if (stock != null) { if (stock > 100) book.setAvailability("In Stock"); else if (stock > 0) book.setAvailability("Low Stock"); else book.setAvailability("Out of Stock"); } }); }
@On(event = "submitOrder") public void handleSubmitOrder(SubmitOrderContext context) { String bookId = context.getBook(); Integer quantity = context.getQuantity();
// Query book CqnSelect select = Select.from(Books_.class).where(b -> b.ID().eq(bookId)); Books book = context.getService().run(select).single(Books.class);
if (book == null) { context.getMessages().error("Book not found"); context.setCompleted(); return; }
if (book.getStock() < quantity) { context.getMessages().error("Insufficient stock"); context.setCompleted(); return; }
// Update stock CqnUpdate update = Update.entity(Books_.class) .data(Books.create().setStock(book.getStock() - quantity)) .where(b -> b.ID().eq(bookId));
context.getService().run(update);
// Emit event OrderPlacedEvent event = OrderPlacedEvent.create(); event.setBook(bookId); event.setQuantity(quantity); context.getService().emit(event);
context.setResult("Order submitted successfully"); }}Advanced Features
1. Aspect-Oriented Programming with Aspects
// Common aspects for reuseaspect managed { createdAt : Timestamp @cds.on.insert: $now; createdBy : User @cds.on.insert: $user; modifiedAt : Timestamp @cds.on.insert: $now @cds.on.update: $now; modifiedBy : User @cds.on.insert: $user @cds.on.update: $user;}
aspect temporal { validFrom : Date @cds.on.insert: $now; validTo : Date default '9999-12-31';}
aspect cuid { key ID : UUID;}
// Use aspects in entitiesentity Orders : cuid, managed, temporal { orderNumber : String(20) @mandatory; customer : Association to Customers; items : Composition of many OrderItems on items.order = $self; totalAmount : Decimal(15,2); status : String(20) @assert.range: ['New', 'Processing', 'Shipped', 'Delivered'];}
entity OrderItems : cuid { order : Association to Orders; product : Association to Products; quantity : Integer @assert.range: [1, 9999]; price : Decimal(15,2);}2. Authorization and Access Control
service AdminService @(requires: 'Admin') {
entity Books as projection on bookshop.Books;
entity Authors as projection on bookshop.Authors;
// Field-level access control @restrict: [ { grant: 'READ', to: 'Admin' }, { grant: ['CREATE', 'UPDATE'], to: 'Admin', where: 'createdBy = $user' } ] entity Orders as projection on bookshop.Orders;}
// Instance-based authorization@restrict: [ { grant: 'READ', to: 'authenticated-user', where: 'createdBy = $user.id' }, { grant: '*', to: 'Admin' }]entity PersonalData { key ID : UUID; userId : String; sensitiveData : String; createdBy : String;}3. Fiori Draft Support
Enable draft mode for edit sessions:
service CatalogService { @odata.draft.enabled entity Books as projection on bookshop.Books;}// Draft is handled automatically, but you can add custom logicthis.before('SAVE', 'Books', async (req) => { // Custom validation before activating draft const book = req.data;
if (!book.ISBN) { req.error(400, 'ISBN is required before publishing'); }
// Validate ISBN format if (!/^\d{10}(\d{3})?$/.test(book.ISBN)) { req.error(400, 'Invalid ISBN format'); }});
this.on('CANCEL', 'Books', async (req) => { // Cleanup when draft is canceled console.log(`Draft ${req.data.ID} was canceled by ${req.user.id}`);});4. Multitenancy
const cds = require('@sap/cds');
cds.on('served', () => { const { Books } = cds.entities('bookshop');
// Tenant-specific initialization cds.on('tenant-subscribe', async (tenant, metadata) => { console.log(`New tenant subscribed: ${tenant}`);
// Initialize tenant-specific data await cds.tx({ tenant }, async (tx) => { await tx.run( INSERT.into(Books).entries([ { title: 'Welcome Book', stock: 10, price: 9.99 } ]) ); });
return 'Subscription successful'; });
cds.on('tenant-unsubscribe', async (tenant) => { console.log(`Tenant unsubscribed: ${tenant}`); // Cleanup tenant data if needed });});
module.exports = cds.server;5. Integration with External Services
// External service definitionusing { API_BUSINESS_PARTNER as bp } from '../srv/external/API_BUSINESS_PARTNER';
service IntegrationService { entity Customers as projection on bp.A_BusinessPartner { BusinessPartner as ID, BusinessPartnerName as name, BusinessPartnerCategory as category };
entity LocalOrders as projection on bookshop.Orders { *, customer.name as customerName };}const cds = require('@sap/cds');
module.exports = async function() { const bupa = await cds.connect.to('API_BUSINESS_PARTNER'); const { Customers, LocalOrders } = this.entities;
// Delegate to external service this.on('READ', 'Customers', async (req) => { return bupa.run(req.query); });
// Hybrid query - local + remote this.after('READ', 'LocalOrders', async (orders) => { const customerIds = orders.map(o => o.customerID);
// Fetch from S/4HANA const customers = await bupa.run( SELECT.from('A_BusinessPartner') .where({ BusinessPartner: { in: customerIds } }) );
const customerMap = new Map( customers.map(c => [c.BusinessPartner, c]) );
// Enrich orders with S/4HANA data orders.forEach(order => { const customer = customerMap.get(order.customerID); if (customer) { order.customerName = customer.BusinessPartnerName; order.customerCategory = customer.BusinessPartnerCategory; } }); });
// Create with external system this.before('CREATE', 'LocalOrders', async (req) => { const { customerID } = req.data;
// Verify customer exists in S/4HANA const customer = await bupa.run( SELECT.one.from('A_BusinessPartner') .where({ BusinessPartner: customerID }) );
if (!customer) { req.error(404, `Customer ${customerID} not found in S/4HANA`); } });};Database Integration
SAP HANA Cloud Artifacts
-- db/src/procedures/GetTopBooks.hdbprocedurePROCEDURE "GetTopBooks"( IN limit INTEGER, OUT results TABLE ( ID NVARCHAR(36), title NVARCHAR(111), totalSales INTEGER, revenue DECIMAL(15,2) ))LANGUAGE SQLSCRIPTSQL SECURITY INVOKERASBEGIN results = SELECT b.ID, b.title, COUNT(oi.ID) as totalSales, SUM(oi.quantity * oi.price) as revenue FROM Books b LEFT JOIN OrderItems oi ON oi.book_ID = b.ID GROUP BY b.ID, b.title ORDER BY totalSales DESC LIMIT :limit;END;// Call HANA procedure from CAPthis.on('getTopBooks', async (req) => { const { limit = 10 } = req.data;
const db = await cds.connect.to('db'); const result = await db.run( `CALL "GetTopBooks"(${limit}, ?)` );
return result;});Calculation Views
<!-- db/src/views/BookSalesView.hdbcalculationview --><?xml version="1.0" encoding="UTF-8"?><Calculation:scenario xmlns:Calculation="http://www.sap.com/ndb/BiModelCalculation.ecore"> <calculationViews> <calculationView xsi:type="Calculation:ProjectionView" id="Books"> <datasources> <DataSource id="BOOKS"> <resourceUri>BOOKS</resourceUri> </DataSource> </datasources> <projectionNode> <mapping xsi:type="Calculation:AttributeMapping" target="ID" source="ID"/> <mapping xsi:type="Calculation:AttributeMapping" target="TITLE" source="TITLE"/> <mapping xsi:type="Calculation:AttributeMapping" target="PRICE" source="PRICE"/> </projectionNode> </calculationView> </calculationViews></Calculation:scenario>Testing
Unit Tests
const cds = require('@sap/cds/lib');const { expect } = require('chai');
describe('Catalog Service', () => { let srv, Books;
before(async () => { srv = await cds.connect.to('CatalogService'); Books = srv.entities.Books; });
it('should return all books', async () => { const books = await srv.read(Books); expect(books).to.be.an('array'); expect(books.length).to.be.greaterThan(0); });
it('should create a new book', async () => { const newBook = { title: 'Test Book', author_ID: 'some-author-id', stock: 10, price: 29.99 };
const result = await srv.create(Books).entries(newBook); expect(result).to.have.property('ID'); });
it('should reject negative price', async () => { try { await srv.create(Books).entries({ title: 'Invalid Book', price: -10 }); expect.fail('Should have thrown error'); } catch (err) { expect(err.message).to.include('Price must be positive'); } });
it('should submit order successfully', async () => { const books = await srv.read(Books).limit(1); const book = books[0];
const result = await srv.send({ method: 'POST', path: '/submitOrder', data: { book: book.ID, quantity: 2 } });
expect(result).to.have.property('success', true); expect(result).to.have.property('orderNumber'); });});Integration Tests
const cds = require('@sap/cds/lib');const { POST, GET, DELETE } = cds.test(__dirname + '/../..');
describe('API Integration Tests', () => {
it('should authenticate and access protected endpoint', async () => { const { data, status } = await GET('/browse/Books', { auth: { username: 'alice', password: '' } });
expect(status).to.equal(200); expect(data.value).to.be.an('array'); });
it('should require authentication', async () => { const { status } = await GET('/browse/Authors'); expect(status).to.equal(401); });
it('should enforce authorization', async () => { const { status } = await POST('/browse/Books', { title: 'Unauthorized Book' }, { auth: { username: 'reader', password: '' } });
expect(status).to.equal(403); });});Performance Optimization
1. Database Query Optimization
// Bad - N+1 query problemthis.after('READ', 'Orders', async (orders) => { for (const order of orders) { order.customer = await SELECT.one.from(Customers) .where({ ID: order.customer_ID }); }});
// Good - Single query with JOINthis.on('READ', 'Orders', async (req, next) => { req.query.SELECT.columns('*', { ref: ['customer'], expand: ['*'] }); return next();});2. Caching
const NodeCache = require('node-cache');const cache = new NodeCache({ stdTTL: 600 }); // 10 minutes
this.on('READ', 'Books', async (req, next) => { const cacheKey = `books_${JSON.stringify(req.query)}`;
// Check cache const cached = cache.get(cacheKey); if (cached) return cached;
// Execute query const result = await next();
// Store in cache cache.set(cacheKey, result);
return result;});3. Streaming Large Results
this.on('READ', 'LargeDataset', async (req) => { const stream = await SELECT.from('LargeDataset') .where(req.query.SELECT.where) .stream();
return stream;});Production Deployment
1. Configuration Management
{ "cds": { "requires": { "db": { "kind": "sql", "[production]": { "kind": "hana", "credentials": {} } }, "auth": { "[production]": { "kind": "xsuaa" } } }, "log": { "levels": { "[production]": { "app": "info", "db": "warn" } } } }}2. Health Checks
module.exports = (app) => { app.get('/health', async (req, res) => { const health = { status: 'UP', timestamp: new Date().toISOString(), checks: {} };
try { // Check database const db = await cds.connect.to('db'); await db.run('SELECT 1 FROM DUMMY'); health.checks.database = 'UP'; } catch (err) { health.checks.database = 'DOWN'; health.status = 'DOWN'; }
const statusCode = health.status === 'UP' ? 200 : 503; res.status(statusCode).json(health); });};Conclusion
SAP CAP dramatically simplifies enterprise application development by providing a comprehensive framework with best practices built-in. From data modeling with CDS to automatic OData API generation, from built-in authentication to seamless HANA integration, CAP enables developers to focus on business logic rather than boilerplate code.
The framework’s flexibility supports both Node.js and Java, making it accessible to a wide range of development teams while maintaining consistency in approach and capabilities.
Master SAP CAP development with our comprehensive SAP BTP training programs. Learn from hands-on labs and real-world scenarios. Contact us for customized enterprise training.