If you have worked with SAP BTP before, you are likely already familiar with the basics of SAP Cloud Application Programming Model (CAP) development, at least within a Trial Account. Rather than starting from the very beginning, this blog assumes a foundational understanding of CAP and focuses on a specific, practical goal: building a SaaS (Software as a Service) multitenant application using the SAP CAP framework, without any database module.
The result is a clean, lightweight SaaS application that demonstrates tenant isolation, subscription lifecycle management, and secure routing — all without the complexity of database provisioning.
What is Multitenancy?
Before we dive in, here is a quick refresher on the concept at the heart of this blog: -
Multitenancy is a software architecture in which a single instance of an application serves multiple customers, known as tenants. Each tenant is logically separated and operates as if they have their own isolated environment, even though they share the same underlying resources — such as the application, infrastructure, and (optionally) databases.
In the context of SAP BTP, the provider is the organization that develops and deploys the application, while the consumers are the subscriber subaccounts that use it. The SaaS Registry service manages this relationship.
Architecture Overview
Our application consists of the following components:
Component | Purpose |
Approuter | Handles tenant isolation, authentication (XSUAA), and request routing |
CAP Service (srv) | Exposes OData services and handles SaaS Registry subscription callbacks |
XSUAA | Provides OAuth2 security with shared tenant mode for multi-tenant JWT handling |
SaaS Registry | Manages tenant subscriptions — onboarding and offboarding lifecycle |
Project Setup
Step 1: Initialize the Project
Start by creating a new CAP project and adding the required modules. Run the following commands in your terminal:
# Create a new CAP project
cds init cap-saas-app
cds add xsuaa approuter
cds add mta
npm installSince this application does not use a database, remove the db/ folder from your project. It is not needed and will keep your project clean.
Add one property to the service module - "APPROUTER_FQDN"
properties:
APPROUTER_FQDN: "${org}-${space}-cap-saas-approuter.${default-domain}"The Approuter is the entry point for all tenant requests. It is responsible for identifying which tenant is making the request, authenticating the user via XSUAA, and forwarding the request to the correct backend service.
The most important configuration for multitenancy is TENANT_HOST_PATTERN . This is a regular expression that the Approuter uses to extract the tenant subdomain from the incoming request hostname.
# In mta.yaml — under the approuter module properties:
TENANT_HOST_PATTERN: "^(.*)-${org}-${space}-${app-name}.${default-domain}"
SAP_JWT_TRUST_ACL:
- clientid: "*"
identityzone: "sap-provisioning"The MTA placeholders resolve at deploy time. For example, in a Trial account with org 7ce04bc6trial and space dev , the approuter FQDN (Fully Qualified Domain Name) becomes:
7ce04bc6trial-dev-cap-saas-approuter.cfapps.us10-001.hana.ondemand.comWhen a consumer with subdomain consumer accesses the application, the full URL becomes:
https://consumer-7ce04bc6trial-dev-cap-saas-approuter.cfapps.us10-001.hana.ondemand.comThe Approuter applies TENANT_HOST_PATTERN to extract consumer as the tenant subdomain, which XSUAA then uses to resolve the correct identity zone.
The SaaS Registry service is the backbone of subscription management. It is defined as a managed service resource in mta.yaml and is responsible for calling your application’s callback endpoints whenever a tenant subscribes or unsubscribes.
resources:
- name: cap-saas-app-registry
type: org.cloudfoundry.managed-service
requires:
- name: srv-api
parameters:
service: saas-registry
service-plan: application
config:
xsappname: cap-saas-app-xsuaa
appName: cap-saas
displayName: 'CAP Multitenant Application'
description: 'A SaaS app built with SAP CAP'
category: "Partner Solutions"
appUrls:
onSubscription: ~{srv-api/srv-url}/callback/v1.0/tenants/{tenantId}
onSubscriptionAsync: false
onUnSubscription: ~{srv-api/srv-url}/callback/v1.0/tenants/{tenantId}
onUnSubscriptionAsync: false
getDependencies: ~{srv-api/srv-url}/callback/v1.0/dependenciesThe appUrls reference the CAP service (srv) URL — not the Approuter. The SaaS Registry calls these endpoints directly during subscription events, bypassing the Approuter.
The xs-security.json file configures your XSUAA instance. For a multitenant application, two settings are critical:
{
"xsappname": "cap-saas-app-xsuaa",
"tenant-mode": "shared",
"scopes": [
{
"name": "$XSAPPNAME.Callback",
"description": "Subscription via SaaS Registry",
"grant-as-authority-to-apps": [
"$XSAPPNAME(application,sap-provisioning,tenant-onboarding)"
]
}
],
"oauth2-configuration": {
"redirect-uris": [
"https://*.cfapps.us10-001.hana.ondemand.com/login/callback",
"https://*.cfapps.us10.hana.ondemand.com/login/callback"
],
"credential-types": ["binding-secret", "x509"]
}
}This is where the magic happens. SAP CAP allows you to extend its bootstrap process via cds.on("bootstrap", ...) . We use this hook to register the raw Express.js routes that the SaaS Registry will call during tenant subscription and unsubscription events.
The bootstrap event is fired immediately after the Express.js app is created, before any CAP middleware or services are added. This makes it the ideal place to register raw REST callbacks. More info: https://cap.cloud.sap/docs/node.js/cds-server#built-in-server-js
When a consumer subscribes, the SaaS Registry calls PUT /callback/v1.0/tenants/:tenantId . Your handler must return the tenant-specific Approuter URL as a plain string. This URL is stored by the SaaS Registry and used to redirect the consumer.
srv/server.js
"use strict";
const cds = require("@sap/cds");
const express = require("express");
cds.on("bootstrap", (app) => {
app.use(express.json());
// Called by SaaS Registry when a tenant subscribes
app.put("/callback/v1.0/tenants/:tenantId", async (req, res) => {
const { tenantId } = req.params;
const body = req.body || {};
const subscribedSubdomain = body.subscribedSubdomain || tenantId;
const approuterFQDN = (process.env.APPROUTER_FQDN || "")
.replace(/^https?:\/\//i, "")
.replace(/\/+$/, "");
const tenantUrl = `https://${subscribedSubdomain}-${approuterFQDN}`;
res.status(200).send(tenantUrl);
});
// Called by SaaS Registry when a tenant unsubscribes
app.delete("/callback/v1.0/tenants/:tenantId", async (req, res) => {
const { tenantId } = req.params;
res.status(200).send(`Tenant ${tenantId} successfully unsubscribed.`);
});
});
module.exports = cds.server;Important: The subscribe callback must return the APPROUTER URL — not the srv (service) URL. Returning the srv URL will cause XSUAA authentication to fail, as the srv app has no login handler.
Now for the application logic. We define a simple service that returns tenant-aware information to the consumer. CAP automatically populates req.tenant , req.user , and req.locale from the validated XSUAA JWT — no manual token parsing required.
The CDS file name and the service implementation file name must match for CAP to auto-wire them. We name both info-service .
// srv/info-service.cds
@requires: 'authenticated-user'
service InfoService {
function getSubaccount() returns String;
}// srv/info-service.js
const cds = require('@sap/cds');
module.exports = cds.service.impl(async function () {
this.on('getSubaccount', async (req) => {
// CAP auto-populates these from the XSUAA JWT
const tenant = req.tenant || 'unknown';
const user = req.user?.id || 'anonymous';
const locale = req.locale || 'en';
return `Tenant: ${tenant} | User: ${user} | Locale: ${locale}`;
});
});CAP Auto-Wiring Convention: The file srv/info-service.js is automatically linked to the service defined in srv/info-service.cds because they share the same filename. If the names do not match, CAP cannot wire them and you will get a 501 “No handler” error at runtime.
# Build the MTA archive
mbt build
# Deploy to Cloud Foundry
cf deploy mta_archives/cap-saas-app_1.0.0.mtarAfter a successful deployment, two applications will be running in your BTP space:
Navigate to your consumer subaccount in the BTP Cockpit and subscribe to CAP Multitenant Application from the Service Marketplace. The SaaS Registry will call your PUT /callback/v1.0/tenants/:tenantId endpoint and store the returned tenant URL.
On BTP Trial, wildcard routes are not permitted. After each subscription, you must manually map a CF route for the consumer tenant:
cf map-route cap-saas-approuter cfapps.us10-001.hana.ondemand.com \
--hostname consumer-7ce04bc6trial-dev-cap-saas-approuterReplace 'consumer' with the actual subdomain of your consumer subaccount. Run 'cf routes' to verify the route was created successfully.
Once the route is mapped and the consumer has subscribed, open the tenant URL in a browser:
Consumer 2 -
The tenant ID corresponds to the consumer subaccount ID in BTP — confirming that full tenant isolation is working correctly.
In this blog, we built a fully functional SaaS multitenant application on SAP BTP using the CAP framework — with no database required. Here is a summary of what we covered:
This architecture serves as a solid foundation. You can extend it further by adding SAP HANA persistence, role-based authorization, a Fiori Elements UI, or async subscription handling as your application grows.
I hope this blog saves you from the pitfalls I encountered along the way. Happy coding! 🚀
References
SAP CAP Documentation: https://cap.cloud.sap/docs
CAP Server Bootstrap: https://cap.cloud.sap/docs/node.js/cds-server#built-in-server-js
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.