Your first SAP CAP service in TypeScript: model, validations and computed fields
Build an OData service for the library with SAP CAP: CDS model, initial data, validation with before and a virtual field with after. All in TypeScript and tested with real requests.
In the RAP series we built the library inside ABAP. Now we do the same outside the system with the SAP Cloud Application Programming Model (CAP): SAP’s programming model for services on BTP with Node.js or Java. If you come from RAP, the mental map is almost the same:
| RAP | CAP |
|---|---|
| Table + CDS View Entity | Entity in db/schema.cds |
| Service Definition | service in srv/*.cds |
| Validation in the Behavior Implementation | before handler |
| Virtual field with logic | virtual + after READ handler |
| OData V4 Service Binding | CAP does it on its own, no configuration |
Requirements
- Node.js 22 or later. CAP 10 does not run on older versions.
- The CAP development tools, installed once globally:
npm i -g @sap/cds-dk
The global package ships the cds command and a copy of the CAP runtime, which is enough to develop on your machine. A server has nothing installed globally, so in step 1 we declare the runtime in the project itself.
1. Create the project
cds init biblioteca --add typescript,sqlite
cd biblioteca
npm install
npm install @sap/cds
npm pkg set name=biblioteca version=1.0.0
npm install @sap/cdsdeclares the CAP runtime as a project dependency.cds initdoes not, and without it the service would not start once deployed.npm pkg setgives the project a name and a version, whichcds initleaves empty. CAP uses them as the title of its start page; without them, it reads “undefined undefined”.
cds init creates the standard layout:
biblioteca/
├── app/ ← user interfaces (not used here)
├── db/ ← data model and initial data
├── srv/ ← services and their logic
├── package.json
└── tsconfig.json
typescript: sets uptsconfig.json,tsxand cds-typer, which generates TypeScript types from your CDS model. This tutorial does not use them yet; if you do, keep in mind thatcds watchonly generates them when they do not exist: after changing a.cdsfile, regenerate them withnpx cds-typer "*".sqlite: a local database for development, with nothing else to install.
2. The data model
db/schema.cds:
namespace biblioteca;
using { cuid, managed } from '@sap/cds/common';
entity Authors : cuid, managed {
name : String(100) @mandatory;
country : String(2);
books : Association to many Books on books.author = $self;
}
entity Books : cuid, managed {
title : String(200) @mandatory;
pages : Integer;
stock : Integer default 0;
author : Association to Authors;
}
cuidadds a UUIDIDkey that CAP generates on create.managedaddscreatedAt,createdBy,modifiedAtandmodifiedBy, which CAP fills in too. It is the equivalent of RAP’sabp_*administrative fields.@mandatorymakes CAP reject a book without a title or an author without a name, with no code.- The two associations form the relationship: a book points to its author, and an author sees all their books (
on books.author = $self).
Association or composition? If
bookswereComposition of many Books, the books would be “part of” the author: deleting an author would delete their books. In a library a book has a life of its own, so we useAssociation. Keep composition for true parent-child relationships, such as an order and its items.
3. Initial data
CAP automatically loads the CSV files in db/data/ named <namespace>-<Entity>.csv.
db/data/biblioteca-Authors.csv:
ID,name,country
a1b2c3d4-0000-4000-8000-000000000001,Ana Rojas,CL
a1b2c3d4-0000-4000-8000-000000000002,Carlos Soto,AR
a1b2c3d4-0000-4000-8000-000000000003,Elena Vidal,ES
db/data/biblioteca-Books.csv:
ID,title,pages,stock,author_ID
b1b2c3d4-0000-4000-8000-000000000010,Aves del sur,320,4,a1b2c3d4-0000-4000-8000-000000000001
b1b2c3d4-0000-4000-8000-000000000011,Ríos de montaña,180,0,a1b2c3d4-0000-4000-8000-000000000001
b1b2c3d4-0000-4000-8000-000000000012,El último tren,410,7,a1b2c3d4-0000-4000-8000-000000000002
b1b2c3d4-0000-4000-8000-000000000013,Cuentos de invierno,120,2,a1b2c3d4-0000-4000-8000-000000000003
- The keys are real UUIDs. Values like
1or2load fine into SQLite, but they are not UUIDs: they look nothing like the ones CAP generates on create and would end up mixed with them in the same table. Test data should have the same shape as real data. - The
authorassociation is filled through theauthor_IDcolumn: CAP stores an association as a foreign key with the_IDsuffix.
4. The service
srv/catalog-service.cds:
using biblioteca as db from '../db/schema';
service CatalogService {
entity Authors as projection on db.Authors;
entity Books as projection on db.Books {
*,
virtual null as available : Boolean
};
}
- The service projects the model’s entities, just like RAP’s
C_projection: it decides what this API exposes. availableis a virtual field: it shows up in the service but does not exist in the database. We compute it in code.
5. The logic in TypeScript
srv/catalog-service.ts. CAP links it automatically because it has the same name as the .cds file:
import cds from '@sap/cds';
export default class CatalogService extends cds.ApplicationService {
async init() {
const { Books } = this.entities;
// Validar antes de escribir: al crear y al modificar
this.before(['CREATE', 'UPDATE'], Books, (req) => {
const { stock } = req.data;
if (stock !== undefined && stock !== null && stock < 0) {
req.error(400, 'El stock no puede ser negativo.', 'stock');
}
});
// Completar el campo virtual después de leer
this.after('READ', Books, (books) => {
for (const book of Array.isArray(books) ? books : [books]) {
if (book && book.stock !== undefined) book.available = book.stock > 0;
}
});
return super.init();
}
}
The two phases you will use most in CAP:
| Phase | What for | Here |
|---|---|---|
before |
Validate or complete the input before it reaches the database. req.error stops the request with an error |
Reject negative stock |
after |
Adjust the result after reading | Compute available |
Three details that prevent subtle bugs:
['CREATE', 'UPDATE']: validating only on create leaves a gap, because aPATCHcould set the stock to a negative value.req.error(400, message, 'stock'): the third argument is the affected field. A Fiori app uses it to highlight the right field in red, just like%elementin RAP.book.stock !== undefined: if the client asks for only some fields ($select=title,available),stockis not in the result. Without that check,availablewould come backfalseeven with copies in stock. This way it is simply left out.
6. Run it
cds watch
cds watch detects the TypeScript project, runs it without compiling, creates an in-memory SQLite database, loads the CSV files and restarts whenever you save a file. The service is available at http://localhost:4004/odata/v4/catalog.
7. Try it
Open http://localhost:4004 in the browser: CAP shows a page with links to each entity, to its $metadata, and to a Fiori preview generated on the fly.
You can also use curl:
Books with their availability:
curl "http://localhost:4004/odata/v4/catalog/Books?\$select=title,stock,available"
Ríos de montaña, with stock 0, comes back with "available": false; the rest with true.
An author with their books ($expand follows the association):
curl "http://localhost:4004/odata/v4/catalog/Authors?\$filter=country%20eq%20'CL'&\$expand=books(\$select=title)"
Try to create a book with negative stock:
curl -X POST http://localhost:4004/odata/v4/catalog/Books \
-H "Content-Type: application/json" \
-d '{"title":"Prueba","stock":-1}'
It answers 400 with "message": "El stock no puede ser negativo." and "target": "stock". The same happens with a PATCH on an existing book.
Creating a book without a title returns 400 with ASSERT_MANDATORY. That comes from @mandatory, without a single line of code.
Summary
db/: the model, withcuidandmanagedso you never repeat keys or administrative fields.srv/*.cds: what the service exposes, includingvirtualfields.srv/*.ts: the logic.beforeto validate the input,afterto complete the result.cds watch: a full OData V4 server, with test data, and nothing to configure.
Next step: add @UI annotations to generate a Fiori Elements app on top of this same service, and deploy it to BTP with SAP HANA Cloud instead of SQLite.
Get new tutorials by email
One email when something new is published. No spam; unsubscribe anytime.
I will email you to confirm your subscription. Data handled under the privacy policy.