Skip to main content

Building Full-Stack SAP CAP Applications: Node.js + UI5 Masterclass

SAP Cloud Application Programming (CAP) model combines the best of modern development: declarative data models, powerful Node.js services, and seamless SAP UI5 integration. This comprehensive guide will walk you through building a complete full-stack application from scratch.

What is SAP CAP?

Core Components

ComponentTechnologyPurpose
CDS ModelsCDS (Core Data Services)Define data models and services
Service LayerNode.js / JavaBusiness logic and APIs
DatabaseSQLite / HANA / PostgreSQLData persistence
UI LayerSAP UI5 / Fiori ElementsUser interface
AuthenticationXSUAA / Mock authSecurity and authorization

Project Setup

Step 1: Initialize CAP Project

# Install CAP CLI globally
npm install -g @sap/cds-dk

# Verify installation
cds version

# Create new project
cds init bookshop --add tiny-sample

# Project structure created:
# bookshop/
# ├── app/              # UI applications
# ├── db/               # Database models
# ├── srv/              # Service definitions & implementations
# ├── package.json
# └── README.md

Step 2: Define Data Model

// db/schema.cds
namespace bookshop;

entity Books {
  key ID       : UUID;
      title    : String(100);
      author   : String(100);
      stock    : Integer;
      price    : Decimal(10, 2);
      genre    : String(50);
      ISBN     : String(20);
}

entity Customers {
  key ID        : UUID;
      firstName : String(50);
      lastName  : String(50);
      email     : String(100);
      phone     : String(20);
      creditLimit : Decimal(10, 2);
      orders    : Association to many Orders on orders.customer = $self;
}

entity Orders {
  key ID        : UUID;
      customer  : Association to Customers;
      book      : Association to Books;
      quantity  : Integer;
      orderDate : DateTime;
      status    : String(20) @assert.range enum {
        PENDING;
        CONFIRMED;
        SHIPPED;
        DELIVERED;
        CANCELLED;
      };
      totalAmount : Decimal(10, 2);
}

Step 3: Define Services

// srv/catalog-service.cds
using bookshop from '../db/schema';

service CatalogService @(path: '/browse') {
  
  @readonly entity Books as projection on bookshop.Books {
    *, genre as category
  } excluding { ISBN };
  
  entity Customers as projection on bookshop.Customers;
  
  entity Orders as projection on bookshop.Orders;
  
  // Custom actions
  action submitOrder(book: UUID, quantity: Integer) returns Orders;
  function getBestSellers() returns array of Books;
  action cancelOrder(order: UUID) returns Boolean;
}

Step 4: Implement Service Logic

// srv/catalog-service.js
const cds = require('@sap/cds');

module.exports = cds.service.impl(async function() {
  const { Books, Orders, Customers } = this.entities;
  
  // Before CREATE: Validate stock availability
  this.before('CREATE', 'Orders', async (req) => {
    const { book, quantity } = req.data;
    
    const bookEntity = await SELECT.one.from(Books).where({ ID: book });
    
    if (!bookEntity) {
      return req.error(404, `Book with ID ${book} not found`);
    }
    
    if (bookEntity.stock < quantity) {
      return req.error(400, `Insufficient stock. Available: ${bookEntity.stock}`);
    }
    
    // Calculate total amount
    req.data.totalAmount = bookEntity.price * quantity;
    req.data.orderDate = new Date().toISOString();
    req.data.status = 'PENDING';
  });
  
  // After CREATE: Update book stock
  this.after('CREATE', 'Orders', async (data, req) => {
    const { book, quantity } = data;
    
    await UPDATE(Books)
      .set({ stock: { '-=': quantity } })  // Decrement stock
      .where({ ID: book });
    
    console.log(`Order ${data.ID} created. Stock updated for book ${book}`);
  });
  
  // Custom action: submitOrder
  this.on('submitOrder', async (req) => {
    const { book, quantity } = req.data;
    const user = req.user;
    
    // Get customer by user email
    const customer = await SELECT.one.from(Customers)
      .where({ email: user.id });
    
    if (!customer) {
      return req.error(404, 'Customer not found. Please register first.');
    }
    
    // Create order
    const order = await INSERT.into(Orders).entries({
      customer_ID: customer.ID,
      book_ID: book,
      quantity: quantity
    });
    
    return order;
  });
  
  // Custom function: getBestSellers
  this.on('getBestSellers', async (req) => {
    const bestSellers = await SELECT.from(Books)
      .columns('ID', 'title', 'author', 'price')
      .where({ stock: { '>': 0 } })
      .orderBy('stock desc')
      .limit(10);
    
    return bestSellers;
  });
  
  // Custom action: cancelOrder
  this.on('cancelOrder', async (req) => {
    const { order } = req.data;
    
    const orderEntity = await SELECT.one.from(Orders).where({ ID: order });
    
    if (!orderEntity) {
      return req.error(404, 'Order not found');
    }
    
    if (orderEntity.status === 'DELIVERED') {
      return req.error(400, 'Cannot cancel delivered order');
    }
    
    // Update order status
    await UPDATE(Orders)
      .set({ status: 'CANCELLED' })
      .where({ ID: order });
    
    // Restore book stock
    await UPDATE(Books)
      .set({ stock: { '+=': orderEntity.quantity } })
      .where({ ID: orderEntity.book_ID });
    
    return true;
  });
  
  // Authorization: Restrict customer data
  this.before('READ', 'Customers', async (req) => {
    const user = req.user;
    
    if (!user.is('admin')) {
      // Non-admin users can only see their own data
      req.query.where({ email: user.id });
    }
  });
  
});

Add Sample Data

// db/data/bookshop-Books.csv
ID;title;author;stock;price;genre;ISBN
1;1984;George Orwell;25;15.99;Fiction;978-0451524935
2;To Kill a Mockingbird;Harper Lee;30;14.99;Fiction;978-0061120084
3;The Great Gatsby;F. Scott Fitzgerald;20;12.99;Fiction;978-0743273565
4;Clean Code;Robert C. Martin;15;42.99;Technology;978-0132350884
5;Design Patterns;Gang of Four;10;54.99;Technology;978-0201633610

// db/data/bookshop-Customers.csv
ID;firstName;lastName;email;phone;creditLimit
c1;John;Doe;john.doe@example.com;+1-555-0001;5000.00
c2;Jane;Smith;jane.smith@example.com;+1-555-0002;7500.00
c3;Bob;Johnson;bob.johnson@example.com;+1-555-0003;3000.00

Run and Test Backend

# Install dependencies
npm install

# Run in development mode (with SQLite)
cds watch

# Server starts at http://localhost:4004
# Service endpoints:
# - http://localhost:4004/browse/Books
# - http://localhost:4004/browse/Orders
# - http://localhost:4004/browse/Customers

# Test with REST client
# GET Books
curl http://localhost:4004/browse/Books

# GET Single Book
curl http://localhost:4004/browse/Books/1

# POST Create Order
curl -X POST http://localhost:4004/browse/Orders \
  -H "Content-Type: application/json" \
  -d '{
    "customer_ID": "c1",
    "book_ID": "1",
    "quantity": 2
  }'

# POST Submit Order (custom action)
curl -X POST http://localhost:4004/browse/submitOrder \
  -H "Content-Type: application/json" \
  -d '{
    "book": "1",
    "quantity": 1
  }'

# GET Best Sellers (custom function)
curl http://localhost:4004/browse/getBestSellers()

Build SAP UI5 Frontend

Step 1: Add UI5 Application

# Generate UI5 app with Fiori tools
cds add fiori

# Or manually create app folder
mkdir -p app/bookshop

# Create manifest.json
# app/bookshop/webapp/manifest.json
{
  "sap.app": {
    "id": "bookshop",
    "type": "application",
    "title": "Bookshop",
    "dataSources": {
      "mainService": {
        "uri": "/browse/",
        "type": "OData",
        "settings": {
          "odataVersion": "4.0"
        }
      }
    }
  },
  "sap.ui5": {
    "models": {
      "": {
        "dataSource": "mainService",
        "settings": {
          "synchronizationMode": "None",
          "operationMode": "Server",
          "autoExpandSelect": true
        }
      }
    },
    "routing": {
      "routes": [
        {
          "pattern": "",
          "name": "BookList",
          "target": "BookList"
        },
        {
          "pattern": "book/{bookId}",
          "name": "BookDetail",
          "target": "BookDetail"
        }
      ],
      "targets": {
        "BookList": {
          "viewName": "BookList",
          "viewLevel": 1
        },
        "BookDetail": {
          "viewName": "BookDetail",
          "viewLevel": 2
        }
      }
    }
  }
}

Step 2: Create Book List View

<!-- app/bookshop/webapp/view/BookList.view.xml -->
<mvc:View
  controllerName="bookshop.controller.BookList"
  xmlns:mvc="sap.ui.core.mvc"
  xmlns="sap.m"
  xmlns:f="sap.f"
  displayBlock="true">
  
  <Page title="Bookshop - Browse Books">
    <content>
      <List
        id="bookList"
        items="{/Books}"
        mode="SingleSelectMaster"
        selectionChange=".onBookSelect">
        
        <StandardListItem
          title="{title}"
          description="{author}"
          info="{price}"
          infoState="{= ${stock} > 10 ? 'Success' : 'Warning' }"
          type="Navigation">
          <customData>
            <core:CustomData key="bookId" value="{ID}" />
          </customData>
        </StandardListItem>
        
      </List>
    </content>
    
    <footer>
      <Toolbar>
        <ToolbarSpacer />
        <Button
          text="View Best Sellers"
          press=".onViewBestSellers"
          type="Emphasized" />
      </Toolbar>
    </footer>
  </Page>
  
</mvc:View>

Step 3: Create Book List Controller

// app/bookshop/webapp/controller/BookList.controller.js
sap.ui.define([
  "sap/ui/core/mvc/Controller",
  "sap/m/MessageToast",
  "sap/m/MessageBox"
], function(Controller, MessageToast, MessageBox) {
  "use strict";

  return Controller.extend("bookshop.controller.BookList", {
    
    onInit: function() {
      // Initialize
    },
    
    onBookSelect: function(oEvent) {
      const oItem = oEvent.getParameter("listItem");
      const bookId = oItem.data("bookId");
      
      // Navigate to detail page
      this.getOwnerComponent().getRouter().navTo("BookDetail", {
        bookId: bookId
      });
    },
    
    onViewBestSellers: function() {
      const oModel = this.getView().getModel();
      
      // Call custom function
      oModel.callFunction("/getBestSellers", {
        method: "GET",
        success: (oData) => {
          MessageBox.information(
            `Top sellers: ${oData.value.map(b => b.title).join(', ')}`
          );
        },
        error: (oError) => {
          MessageBox.error("Failed to load best sellers");
        }
      });
    }
    
  });
});

Step 4: Create Book Detail View

<!-- app/bookshop/webapp/view/BookDetail.view.xml -->
<mvc:View
  controllerName="bookshop.controller.BookDetail"
  xmlns:mvc="sap.ui.core.mvc"
  xmlns="sap.m"
  xmlns:f="sap.ui.layout.form">
  
  <Page
    title="{title}"
    showNavButton="true"
    navButtonPress=".onNavBack">
    
    <content>
      <ObjectHeader
        title="{title}"
        number="{price}"
        numberUnit="USD"
        intro="{author}">
        
        <attributes>
          <ObjectAttribute title="Genre" text="{genre}" />
          <ObjectAttribute title="Stock" text="{stock}" />
          <ObjectAttribute title="ISBN" text="{ISBN}" />
        </attributes>
        
      </ObjectHeader>
      
      <f:SimpleForm
        editable="true"
        layout="ResponsiveGridLayout">
        
        <f:content>
          <Label text="Quantity" />
          <StepInput
            id="quantityInput"
            value="1"
            min="1"
            max="{stock}"
            width="150px" />
          
          <Button
            text="Order Book"
            press=".onOrderBook"
            type="Emphasized" />
        </f:content>
        
      </f:SimpleForm>
    </content>
    
  </Page>
  
</mvc:View>

Step 5: Create Book Detail Controller

// app/bookshop/webapp/controller/BookDetail.controller.js
sap.ui.define([
  "sap/ui/core/mvc/Controller",
  "sap/m/MessageToast",
  "sap/m/MessageBox"
], function(Controller, MessageToast, MessageBox) {
  "use strict";

  return Controller.extend("bookshop.controller.BookDetail", {
    
    onInit: function() {
      const oRouter = this.getOwnerComponent().getRouter();
      oRouter.getRoute("BookDetail").attachMatched(this._onRouteMatched, this);
    },
    
    _onRouteMatched: function(oEvent) {
      const bookId = oEvent.getParameter("arguments").bookId;
      
      // Bind view to selected book
      this.getView().bindElement({
        path: `/Books(${bookId})`
      });
    },
    
    onNavBack: function() {
      this.getOwnerComponent().getRouter().navTo("BookList");
    },
    
    onOrderBook: async function() {
      const oView = this.getView();
      const oModel = oView.getModel();
      const oContext = oView.getBindingContext();
      const bookId = oContext.getProperty("ID");
      const quantity = this.byId("quantityInput").getValue();
      
      // Call custom action: submitOrder
      const oAction = oModel.bindContext(`/submitOrder(...)`);
      oAction.setParameter("book", bookId);
      oAction.setParameter("quantity", parseInt(quantity));
      
      try {
        await oAction.execute();
        const oResult = oAction.getBoundContext().getObject();
        
        MessageBox.success(
          `Order ${oResult.ID} placed successfully!\nTotal: $${oResult.totalAmount}`,
          {
            onClose: () => {
              // Refresh book data to show updated stock
              oContext.refresh();
            }
          }
        );
        
      } catch (error) {
        MessageBox.error(`Order failed: ${error.message}`);
      }
    }
    
  });
});

Authentication and Authorization

Add Mock Users for Development

// .cdsrc.json
{
  "requires": {
    "auth": {
      "kind": "mocked",
      "users": {
        "john.doe@example.com": {
          "password": "password",
          "ID": "john.doe@example.com",
          "roles": ["customer"]
        },
        "admin@bookshop.com": {
          "password": "admin",
          "ID": "admin@bookshop.com",
          "roles": ["admin"]
        }
      }
    }
  }
}

Add Authorization Annotations

// srv/catalog-service.cds
using bookshop from '../db/schema';

service CatalogService @(path: '/browse') @(requires: 'authenticated-user') {
  
  @readonly entity Books as projection on bookshop.Books;
  
  @restrict: [
    { grant: 'READ', to: 'authenticated-user' },
    { grant: ['CREATE', 'UPDATE', 'DELETE'], to: 'admin' }
  ]
  entity Customers as projection on bookshop.Customers;
  
  @restrict: [
    { grant: 'READ', to: 'authenticated-user', where: 'customer.email = $user' },
    { grant: 'CREATE', to: 'authenticated-user' },
    { grant: '*', to: 'admin' }
  ]
  entity Orders as projection on bookshop.Orders;
  
  action submitOrder(book: UUID, quantity: Integer) returns Orders;
}

Deploy to SAP BTP

Add Cloud Configuration

# mta.yaml
_schema-version: "3.1"
ID: bookshop
version: 1.0.0

modules:
  - name: bookshop-srv
    type: nodejs
    path: gen/srv
    requires:
      - name: bookshop-db
    provides:
      - name: srv-api
        properties:
          srv-url: ${default-url}

  - name: bookshop-db-deployer
    type: hdb
    path: gen/db
    requires:
      - name: bookshop-db

resources:
  - name: bookshop-db
    type: com.sap.xs.hdi-container
    properties:
      hdi-service-name: ${service-name}

  - name: bookshop-uaa
    type: org.cloudfoundry.managed-service
    parameters:
      service: xsuaa
      service-plan: application

Build and Deploy

# Build MTA archive
cds build --production
mbt build

# Deploy to Cloud Foundry
cf deploy mta_archives/bookshop_1.0.0.mtar

# Or deploy to Kyma
kubectl apply -f deployment.yaml

Testing

Unit Tests for Service

// test/catalog-service.test.js
const cds = require('@sap/cds/lib');
const { expect } = require('chai');

describe('CatalogService', () => {
  let catalogService;
  
  before(async () => {
    catalogService = await cds.connect.to('CatalogService');
  });
  
  it('should read books', async () => {
    const { Books } = catalogService.entities;
    const books = await SELECT.from(Books);
    expect(books).to.have.length.greaterThan(0);
  });
  
  it('should create order with sufficient stock', async () => {
    const { Orders } = catalogService.entities;
    
    const order = await INSERT.into(Orders).entries({
      customer_ID: 'c1',
      book_ID: '1',
      quantity: 1
    });
    
    expect(order).to.have.property('ID');
  });
  
  it('should reject order with insufficient stock', async () => {
    const { Orders } = catalogService.entities;
    
    try {
      await INSERT.into(Orders).entries({
        customer_ID: 'c1',
        book_ID: '1',
        quantity: 1000
      });
      expect.fail('Should have thrown error');
    } catch (error) {
      expect(error.message).to.include('Insufficient stock');
    }
  });
});

Best Practices

PracticeDescription
Use CDS AnnotationsLeverage @readonly, @restrict for declarative security
Implement Before/After HandlersValidation in before, side effects in after
Custom Actions for Complex LogicUse actions/functions for business operations
Proper Error HandlingUse req.error() with meaningful messages
Test CoverageUnit tests for services, integration tests for flows

Conclusion

SAP CAP provides a modern, productive framework for full-stack development:

  • CDS for declarative data modeling
  • Node.js for powerful backend logic
  • SAP UI5 for enterprise-grade UI
  • Built-in security and authorization
  • Cloud-ready deployment

SAP CAP bridges SAP heritage with modern cloud-native development practices.

About the Author: Yogesh Pandey is a passionate developer and consultant specializing in SAP technologies and full-stack development.