Excel

Background

Early January, 2026, we had concluded first stage of employee annual performance appraisal at departmental level. We now awaited feedback from directorate who are in charge of moderation of performance appraisal feedback scores awarded to each employee by the departmental head. The second stage (moderation) considers the overall performance of the organization, the moderation committee ‘opinion’ about the department among other ‘things.’ In late February, 2026, we received a communication rejecting appraisal results of all employees in the department plus departmental head’s recommendations. One comment stood out in the communication; “the department has not presented any dashboard to demonstrate individual employee’s productivity, improvements on performance measures and so on to justify promotions or any rewards’. I then asked myself two questions. If the agreed performance measures are all captured in an online Human Resource Management System application (HRMS) depending on each employee’s Job Description (JD) and the HRMS has computed the employee’ performance scores, which other ‘things’ are being reviewed at moderation stage? Why is the data captured in the application not being used to create dashboards to help answer questions raised at moderation stage? Based on my years of experience, I had always felt that the performance measures as captured in HRMS were general and would not be sufficient to create any dashboards. Using excel application, this article will attempt to create a simple dashboard which could have been used to monitor one Key Performance Indicator (KPI) on monthly basis and may have been used to measure performance. The critical indicator that has direct impact on customer service is Turn Around Time (TAT).

This article is written in fulfilment of an assignment and as such, it is not to be relied on entirely for academic writing.

Introduction

Microsoft Excel (Excel) was introduced in the 1980s and the initial release included features which in today’s standard are considered basic. The features included cell formatting, simple formulas, and charting capabilities. The Excel ease of use and capabilities made it a favourite and popular appli
 cation among its users. This background became the foundation for what would become the world’s dorminant spreadsheet application used in almost every industry.

Fast forward, the latest iteration of Excel includes capabilities such as puling live information such as scientific figures, market trends and historical statistics from trusted online sources into a worksheet. It has also introduced a wide range of formulas to help in data manipulations as well as AI-based data cleaning feature to address inconsistent text, number formats, and extra spaces thereby reducing time spent on manual data cleaning. These enhancements position Excel as more than just as a spreadsheet tool since the intelligent data platform capabilities enable automation of analysis, connecting to live external data sources and so on hence transforming how professionals work with data.

Excel Formulas & Features

Excel has well over 500 formulas which are broadly categorized as financial, statistical, logical, text, lookup, engineering, and date/time. Due to ever increasing number of new features being released and added to the software, it has become difficult to keep track of all the updates.

Below monitoring feature was developed using hypothetical statistical data for three months period. A simple ‘nested IF’ formular was used to establish number of credit files reviewed within set TAT and those with delayed TAT. A simple pivot table was extracted from the data to summarized the findings and simple line graph plotted to visualize trend of TAT compliance. Another excel feature; ‘slicer’ was added to make it easier for reviewer to interact with the line graph.

Conclusion

Excel has becoming a tool that can be used to perform more functions and as such, any task which generates, collects or processes data should aim at creating a visual summary inform of a dashboard that communicates or monitors if everything is on track to help achieve a bigger goal.

CAP Advanced Patterns — Drafts, i18n, Audit Logging & Production Readiness

Lesson 3.4 — CAP Advanced Patterns & Production Readiness

Table of Contents

  • 1. Draft Handling
  • 2. Localization (i18n)
  • 3. Temporal Data & Audit Logging
  • 4. Deployment Topologies
  • 5. Performance Optimization
  • Top 5 Pitfalls
  • What to Learn Next

Summary: Production CAP Java applications require mastering draft handling for Fiori UIs, localization/i18n, temporal data, deployment topologies (MTA vs Helm), and performance optimization. This lesson covers these advanced patterns with practical examples and configuration.

1. Draft Handling

What is Draft?

Draft enables Fiori’s “edit session” pattern: a user opens an entity for editing, makes changes over multiple requests, then saves or discards. Changes are stored in a draft table until explicitly activated.

User clicks "Edit"     User modifies fields     User clicks "Save"
       │                      │                        │
       ▼                      ▼                        ▼
┌──────────────┐    ┌──────────────┐    ┌──────────────────────┐
│ DRAFT_NEW    │    │ Draft table  │    │ Copy draft → active  │
│ creates draft│───→│ updated      │───→│ Delete draft          │
│ copy         │    │ (multiple    │    │ Return active entity  │
│              │    │  requests)   │    │                       │
└──────────────┘    └──────────────┘    └──────────────────────┘

CDS Annotation

service AdminService {
    @odata.draft.enabled
    entity Books as projection on db.Books;
}

This auto-generates:

  • DRAFT_Books table for storing in-progress edits
  • Draft-specific OData actions: draftEdit, draftActivate, draftPrepare

Draft Handler in Java

@Component
@ServiceName(AdminService_.CDS_NAME)
public class AdminServiceHandler implements EventHandler {

    // Runs when user clicks "Save" (draft → active)
    @Before(event = DraftService.EVENT_DRAFT_SAVE, entity = Books_.CDS_NAME)
    public void validateOnSave(DraftSaveEventContext context) {
        CqnSelect draftQuery = Select.from(Books_.class)
            .where(b -> b.IsActiveEntity().eq(false)  // draft version
                .and(b.ID().eq(context.getCqn().entries().get(0).get("ID"))));

        Books draft = db.run(draftQuery).single(Books.class);

        if (draft.getTitle() == null || draft.getTitle().isBlank()) {
            throw new ServiceException(ErrorStatuses.BAD_REQUEST,
                "Title is required before saving");
        }
        if (draft.getPrice() == null || draft.getPrice().compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException(ErrorStatuses.BAD_REQUEST,
                "Price must be positive");
        }
    }

    // Runs during draft preparation (field validation while editing)
    @Before(event = DraftService.EVENT_DRAFT_PREPARE, entity = Books_.CDS_NAME)
    public void prepareWarnings(DraftPrepareEventContext context) {
        // Side-effect free validation — add warnings but don't reject
        Messages messages = context.getMessages();
        // Add warnings that show in Fiori UI but don't block save
    }
}

Draft Timeout

cds:
  drafts:
    cancellation-timeout: 15d  # Auto-delete drafts after 15 days

2. Localization (i18n)

CDS Text Bundles

// db/schema.cds
entity Books {
    key ID : UUID;
    title  : localized String(200);  // enables translations
    descr  : localized String(2000);
}

The localized keyword generates a Books.texts table:

-- Auto-generated
CREATE TABLE my_bookshop_Books_texts (
    locale VARCHAR(14),
    ID UUID,
    title VARCHAR(200),
    descr VARCHAR(2000),
    PRIMARY KEY (locale, ID)
);

i18n Properties Files

# srv/src/main/resources/i18n/messages.properties (default/English)
book_not_found=Book with ID {0} not found
order_submitted=Order {0} submitted successfully
insufficient_stock=Not enough stock for book {0}. Available: {1}

# srv/src/main/resources/i18n/messages_de.properties (German)
book_not_found=Buch mit ID {0} nicht gefunden
order_submitted=Bestellung {0} erfolgreich eingereicht
insufficient_stock=Nicht genug Bestand für Buch {0}. Verfügbar: {1}

Using Localized Messages in Handlers

@On(event = "submitOrder")
public void onSubmitOrder(SubmitOrderContext context) {
    Books book = db.run(Select.from(Books_.class).byId(context.getBook()))
        .first(Books.class)
        .orElseThrow(() -> new ServiceException(ErrorStatuses.NOT_FOUND,
            MessageKeys.BOOK_NOT_FOUND, context.getBook()));

    if (book.getStock() < context.getQuantity()) {
        throw new ServiceException(ErrorStatuses.CONFLICT,
            MessageKeys.INSUFFICIENT_STOCK, book.getTitle(), book.getStock());
    }
    // ... process order
}

3. Temporal Data & Audit Logging

Managed Aspects (Auto-filled Fields)

using { managed, cuid } from '@sap/cds/common';

entity Orders : cuid, managed {
    // cuid provides: key ID : UUID;
    // managed provides: createdAt, createdBy, modifiedAt, modifiedBy
    orderNo    : String(20);
    totalAmount: Decimal(15,2);
    status     : String(20);
}

CAP automatically fills these fields:

  • createdAt / modifiedAt — server timestamp
  • createdBy / modifiedBy — from JWT user ID

Audit Logging with @changelog

@changelog: [title, price, stock]
entity Books : managed {
    key ID : UUID;
    title  : String(200);
    price  : Decimal(10,2);
    stock  : Integer;
}

CAP generates change log entries for annotated fields, recording old value, new value, user, and timestamp.

Soft Delete Pattern

entity Orders : managed {
    key ID      : UUID;
    // ... fields ...
    deletedAt   : Timestamp;
    deletedBy   : String(255);
}

// Service shows only non-deleted
service OrderService {
    entity Orders as projection on db.Orders
        where deletedAt is null;
}
@On(event = CqnService.EVENT_DELETE, entity = Orders_.CDS_NAME)
public void softDelete(CdsDeleteEventContext context) {
    // Instead of physical delete, set deleted fields
    String orderId = extractId(context);
    CqnUpdate update = Update.entity(Orders_.class)
        .data("deletedAt", Instant.now())
        .data("deletedBy", context.getUserInfo().getName())
        .byId(orderId);
    db.run(update);
    context.setResult(List.of(Map.of("ID", orderId)));
    context.setCompleted(); // prevent generic delete handler
}

4. Deployment Topologies

MTA (Cloud Foundry)

# mta.yaml
_schema-version: "3.1"
ID: my-cap-app
version: 1.0.0

modules:
  - name: my-cap-srv
    type: java
    path: srv
    parameters:
      memory: 1024M
      buildpack: sap_java_buildpack_jakarta
    properties:
      SPRING_PROFILES_ACTIVE: cloud
      JBP_CONFIG_COMPONENTS: '{jres: ["com.sap.xs.java.buildpack.jre.SAPMachineJRE"]}'
    requires:
      - name: my-xsuaa
      - name: my-hana
      - name: my-destination

  - name: my-cap-db-deployer
    type: hdb
    path: gen/db
    requires:
      - name: my-hana

resources:
  - name: my-xsuaa
    type: org.cloudfoundry.managed-service
    parameters:
      service: xsuaa
      service-plan: application
      path: ./xs-security.json

  - name: my-hana
    type: com.sap.xs.hdi-container
    parameters:
      service: hana
      service-plan: hdi-shared

  - name: my-destination
    type: org.cloudfoundry.managed-service
    parameters:
      service: destination
      service-plan: lite

Helm (Kyma)

my-cap-helm/
├── Chart.yaml
├── values.yaml
├── templates/
│   ├── deployment-srv.yaml
│   ├── deployment-sidecar.yaml    # MTX sidecar (if multi-tenant)
│   ├── service.yaml
│   ├── apirule.yaml
│   ├── service-instance-xsuaa.yaml
│   ├── service-instance-hana.yaml
│   ├── service-binding-xsuaa.yaml
│   ├── service-binding-hana.yaml
│   └── job-db-deploy.yaml          # HDI deployer as K8s Job

CF vs Kyma Deployment Comparison

Aspect Cloud Foundry (MTA) Kyma (Helm)
Packaging MTA archive (.mtar) Docker image + Helm chart
DB deployment hdb module (auto) K8s Job with HDI deployer image
Service binding MTA requires BTP Operator CRDs
Scaling CF instances parameter K8s HPA
Routing CF routes + approuter APIRule + Istio
Sidecar (MTX) Separate CF app Separate K8s Deployment

5. Performance Optimization

Query Optimization

// BAD: fetches all columns, all rows
CqnSelect bad = Select.from(Books_.class);

// GOOD: project only needed columns, filter, limit
CqnSelect good = Select.from(Books_.class)
    .columns(b -> b.ID(), b -> b.title(), b -> b.price())
    .where(b -> b.stock().gt(0))
    .orderBy(b -> b.title().asc())
    .limit(50);

Batch Operations

// BAD: N individual inserts
for (Map<String, Object> book : books) {
    db.run(Insert.into(Books_.class).entry(book));
}

// GOOD: single batch insert
db.run(Insert.into(Books_.class).entries(books));

Connection Pool Tuning

spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      minimum-idle: 2
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000

Caching with Spring Cache

@Cacheable(value = "genres", key = "'all'")
public List<Genres> getAllGenres() {
    return db.run(Select.from(Genres_.class)).listOf(Genres.class);
}

@CacheEvict(value = "genres", allEntries = true)
public void onGenreChanged() {
    // Called when genres are modified
}

Top 5 Pitfalls

  1. Not enabling draft for Fiori edit scenarios. Without @odata.draft.enabled, Fiori Elements uses “direct edit” mode which doesn’t support multi-step editing or concurrent user detection.
  2. Ignoring the localized keyword. Adding translations retroactively requires database migration. Plan for i18n from the start using localized on user-facing String fields.
  3. Deploying without a startup probe. CAP Java apps with CDS compilation at startup can take 30-60s. Without a startup probe, Kubernetes kills the pod before it’s ready.
  4. Not batching database operations. Individual inserts/updates in a loop are orders of magnitude slower than batch operations. Always use .entries(list).
  5. MTA descriptor out of sync with actual services. Forgetting to add a new service to mta.yaml causes deployment failures. Keep the MTA descriptor as the definitive deployment manifest.

What to Learn Next

  • Lesson 3.5: CAP Fiori Elements — UI annotations for automatic UI generation
  • Lesson 1.4: Multi-Tenancy — MTX sidecar deployment patterns
  • Lesson 4.1: CI/CD — automating MTA builds and Helm deployments
  • Lesson 4.2: Observability — monitoring CAP applications in production

BiRefNet vs rembg vs U2Net: Which Background Removal Model Actually Works in Production?

BiRefNet vs rembg vs U2Net: Which Background Removal Model Actually Works in Production?

I’ve spent the last few months running background removal at scale — tens of thousands of images through different models — and the difference between them is much larger than the benchmarks suggest.

Here’s the honest breakdown.

Why This Matters More Than You Think

Background removal sounds like a solved problem. It isn’t.

The failure cases are brutal: hair strands that become blocky halos, glass objects that disappear, products on white backgrounds that partially vanish, semi-transparent fabric that turns opaque. Each model fails differently, and the failures often only show up at scale.

The Three Models

rembg — the classic. Wraps ISNet and U2Net under a unified API. Widely used, easy to run locally, but struggles with fine detail like hair, fur, and transparent objects. Good for simple product shots with clear subject-background contrast.

U2Net — the academic ancestor. Solid general-purpose segmentation but trained mostly on salient object detection tasks, not specifically on product photography or people. Fast, low VRAM.

BiRefNet — state of the art as of 2025. Bilateral Reference Network uses high-resolution reference features to preserve fine-grained edges. Handles hair, transparent glass, complex fabric, and multi-object scenes significantly better than both alternatives.

Benchmark: 500 Real Product Images

I ran the same 500-image batch (mix of apparel, electronics, food, cosmetics) through all three:

Model Hair accuracy Glass/transparent Avg inference Overall quality
U2Net 71% 48% 0.8s Acceptable
rembg/ISNet 81% 59% 1.1s Good
BiRefNet 94% 78% 1.4s Excellent

These aren’t cherry-picked. The 6% gap in hair accuracy translates to roughly 30 images per 500 batch needing manual touch-up — at any real volume, that eliminates the cost savings.

Code Comparison

Running rembg locally:

from rembg import remove
from PIL import Image
import io

input_image = Image.open("product.jpg")
output = remove(input_image)
output.save("output.png")

Works fine locally. The catch: rembg on CPU is 3-8 seconds/image. On GPU, needs CUDA setup, model downloads, dependency management. Fine for a one-off script, painful to scale.

BiRefNet via API (no infrastructure):

import requests

response = requests.post(
    "https://api.pixelapi.dev/v1/edit",
    headers={"Authorization": "Bearer YOUR_KEY"},
    json={"operation": "remove-bg", "image_url": "https://yourcdn.com/product.jpg"}
)
clean_url = response.json()["output_url"]  # Transparent PNG, <2s

Same BiRefNet model, no GPU setup, no dependency hell.

When to Use Each

Use rembg/U2Net if:

  • You’re doing occasional local processing
  • Simple product images with solid backgrounds
  • You want zero API dependency

Use BiRefNet if:

  • You need consistent quality at scale
  • Your images include people, hair, apparel, or glass
  • You’re building something that customers will actually see

The Hidden Cost of “Good Enough”

At 10,000 images/month, a 10% quality failure rate means 1,000 images need manual review. At even modest labor costs, that dwarfs the difference between a cheap API and a quality one.

BiRefNet runs on PixelAPI at 10 credits/image. At the Starter plan, that’s 1,000 images for the monthly base cost. The math changes fast when you factor in the manual correction rate you’re avoiding.

Try It

Free credits at pixelapi.dev — no card needed. Run your hardest test images through it.

PixelAPI runs BiRefNet on dedicated RTX GPUs. No cold starts, results in under 2 seconds.

CAP Java Architecture — CDS, Event Handlers & CQN Query API

Lesson 3.1 — CAP Java Architecture & Core Concepts

Table of Contents

  • 1. CAP Java Architecture Overview
  • 2. CDS Compilation Pipeline
  • 3. Event Handler Model
  • 4. CQN Query API
  • 5. Project Structure
  • Top 5 Pitfalls
  • What to Learn Next

Summary: The SAP Cloud Application Programming Model (CAP) for Java provides a framework built on Spring Boot that adds CDS-based domain modeling, a generic runtime for OData/REST services, an event-driven handler model, and built-in BTP service integration. This lesson covers the CAP Java architecture, CDS compilation pipeline, the event/handler model, CQN query API, and how CAP compares to pure Spring Boot development.

1. CAP Java Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                    CAP Java Application                      │
│                                                              │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  CDS Model Layer (.cds files)                        │    │
│  │  - Domain entities, types, associations              │    │
│  │  - Service definitions (projections, actions)        │    │
│  │  - Annotations (UI, auth, validation)                │    │
│  └──────────────────────┬──────────────────────────────┘    │
│                         │ cds compile → CSN (JSON)           │
│  ┌──────────────────────▼──────────────────────────────┐    │
│  │  CAP Java Runtime                                    │    │
│  │  ┌──────────────┐ ┌──────────────┐ ┌─────────────┐  │    │
│  │  │ OData V4     │ │ REST         │ │ Messaging    │  │    │
│  │  │ Adapter      │ │ Adapter      │ │ Adapter      │  │    │
│  │  └──────┬───────┘ └──────┬───────┘ └──────┬──────┘  │    │
│  │         │                │                │          │    │
│  │  ┌──────▼────────────────▼────────────────▼──────┐   │    │
│  │  │            Generic Event Processor             │   │    │
│  │  │  (dispatches CQN events to handlers)           │   │    │
│  │  └──────────────────────┬─────────────────────────┘   │    │
│  │                         │                              │    │
│  │  ┌──────────────────────▼──────────────────────────┐  │    │
│  │  │         Custom Event Handlers (your code)        │  │    │
│  │  │  @Before / @On / @After                          │  │    │
│  │  └──────────────────────┬──────────────────────────┘  │    │
│  │                         │                              │    │
│  │  ┌──────────────────────▼──────────────────────────┐  │    │
│  │  │         Persistence Layer                        │  │    │
│  │  │  CQN → SQL (HANA / H2 / PostgreSQL)             │  │    │
│  │  └─────────────────────────────────────────────────┘  │    │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │  Spring Boot Foundation                                 │  │
│  │  DI, Auto-config, Actuator, Security, Web Server        │  │
│  └────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

CAP = Spring Boot + CDS + Generic Runtime

What CAP Adds What Spring Boot Provides
CDS modeling language Dependency injection
Generic CRUD for OData/REST Web server (Tomcat)
Event-driven handler model Auto-configuration
CQN query abstraction Actuator, profiles
BTP service integration Security framework
Draft support Transaction management
Fiori annotations Testing infrastructure

2. CDS Compilation Pipeline

From .cds to Runtime

.cds files          cds compile          CSN (JSON)          CAP Runtime

schema.cds    ─┐                    ┌─ db/csn.json      ─┐
service.cds   ─┤──→ cds compile ──→├─ srv/csn.json     ─┤──→ Java classes
annotations.cds─┘                    └─ EDMX metadata    ─┘    + generic handlers

Domain Model (schema.cds)

namespace my.bookshop;

entity Books {
    key ID     : UUID;
    title      : String(200)  @mandatory;
    descr      : String(2000);
    author     : Association to Authors;
    genre      : Association to Genres;
    stock      : Integer      @assert.range: [0, 999];
    price      : Decimal(10,2);
    currency   : Currency;
    createdAt  : Timestamp    @cds.on.insert: $now;
    modifiedAt : Timestamp    @cds.on.insert: $now  @cds.on.update: $now;
}

entity Authors {
    key ID    : UUID;
    name      : String(100)  @mandatory;
    books     : Composition of many Books on books.author = $self;
    country   : Country;
}

entity Genres : sap.common.CodeList {
    key ID    : Integer;
    parent    : Association to Genres;
    children  : Composition of many Genres on children.parent = $self;
}

Service Definition (service.cds)

using my.bookshop as db from '../db/schema';

service CatalogService @(path: '/catalog') {

    @readonly
    entity Books as projection on db.Books {
        *, author.name as authorName
    } excluding { createdAt, modifiedAt };

    @requires: 'Admin'
    entity Authors as projection on db.Authors;

    // Unbound action
    action submitOrder(book : Books:ID, quantity : Integer) returns { stock : Integer };

    // Bound function
    function getBooksByAuthor(authorId : Authors:ID) returns array of Books;
}

What Gets Generated

From these CDS files, the CAP build produces:

  1. CSN (Core Schema Notation) — JSON representation of the model
  2. EDMX — OData V4 metadata document
  3. HDI artifacts — SQL tables, views for HANA deployment
  4. Java interfaces — Optional typed accessor interfaces

3. Event Handler Model

Handler Registration

@Component
@ServiceName(CatalogService_.CDS_NAME)
public class CatalogServiceHandler implements EventHandler {

    @Autowired
    private PersistenceService db;

    // BEFORE: validation, enrichment (runs before generic handler)
    @Before(event = CqnService.EVENT_CREATE, entity = Books_.CDS_NAME)
    public void validateBook(CdsCreateEventContext context) {
        context.getCqn().entries().forEach(book -> {
            String title = (String) book.get("title");
            if (title == null || title.isBlank()) {
                throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Title is required");
            }
        });
    }

    // ON: replace generic handler (full control)
    @On(event = CqnService.EVENT_READ, entity = Books_.CDS_NAME)
    public void readBooks(CdsReadEventContext context) {
        // Custom read logic (e.g., external API call)
        CqnSelect query = context.getCqn();
        Result result = db.run(query);
        context.setResult(result);
    }

    // AFTER: post-processing (modify result before returning)
    @After(event = CqnService.EVENT_READ, entity = Books_.CDS_NAME)
    public void afterReadBooks(CdsReadEventContext context) {
        context.getResult().forEach(book -> {
            Integer stock = (Integer) book.get("stock");
            book.put("availability", stock > 0 ? "In Stock" : "Out of Stock");
        });
    }

    // Custom action handler
    @On(event = "submitOrder")
    public void onSubmitOrder(SubmitOrderContext context) {
        String bookId = context.getBook();
        Integer quantity = context.getQuantity();

        CqnSelect select = Select.from(Books_.class).byId(bookId);
        Books book = db.run(select).single(Books.class);

        if (book.getStock() < quantity) {
            throw new ServiceException(ErrorStatuses.CONFLICT, "Not enough stock");
        }

        book.setStock(book.getStock() - quantity);
        CqnUpdate update = Update.entity(Books_.class).data(book);
        db.run(update);

        context.setResult(Map.of("stock", book.getStock()));
        context.setCompleted();
    }
}

Event Processing Order

HTTP Request (OData/REST)
    │
    ▼
┌─────────────┐
│  @Before     │ ← Validation, enrichment, authorization checks
│  handlers    │   (multiple handlers possible, ordered by @HandlerOrder)
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  @On         │ ← Core logic (generic CRUD or your custom handler)
│  handler     │   Only ONE @On handler executes (first registered wins)
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  @After      │ ← Post-processing (modify result, trigger side effects)
│  handlers    │   (multiple handlers possible)
└──────┬──────┘
       │
       ▼
HTTP Response

4. CQN Query API

CQN (CDS Query Notation) is CAP’s type-safe query builder, analogous to JPA Criteria API or jOOQ:

Select Queries

// Simple select
CqnSelect query = Select.from(Books_.class);

// With filter
CqnSelect query = Select.from(Books_.class)
    .where(b -> b.stock().gt(0)
        .and(b.price().lt(50)));

// With expand (join)
CqnSelect query = Select.from(Books_.class)
    .columns(b -> b._all(), b -> b.author().expand())
    .where(b -> b.genre().name().eq("Fiction"))
    .orderBy(b -> b.title().asc())
    .limit(10, 0);

// By key
CqnSelect byId = Select.from(Books_.class)
    .byId("550e8400-e29b-41d4-a716-446655440000");

Insert, Update, Delete

// Insert
Map<String, Object> bookData = Map.of(
    "ID", UUID.randomUUID().toString(),
    "title", "Clean Code",
    "price", 29.99,
    "stock", 100
);
CqnInsert insert = Insert.into(Books_.class).entry(bookData);
Result result = db.run(insert);

// Update
CqnUpdate update = Update.entity(Books_.class)
    .data("stock", 50)
    .byId(bookId);
db.run(update);

// Upsert
CqnUpsert upsert = Upsert.into(Books_.class).entry(bookData);
db.run(upsert);

// Delete
CqnDelete delete = Delete.from(Books_.class)
    .where(b -> b.stock().eq(0));
db.run(delete);

CQN vs JPA/Spring Data

CQN (CAP) JPA / Spring Data
Select.from(Books_.class) bookRepository.findAll()
.where(b -> b.stock().gt(0)) @Query("SELECT b FROM Book b WHERE b.stock > 0")
.columns(b -> b.title(), b -> b.price()) Projection interfaces
db.run(query).listOf(Books.class) Repository method return
CDS associations (auto-join) @ManyToOne, @OneToMany
No entity manager / session EntityManager, L1/L2 cache

5. Project Structure

my-cap-project/
├── pom.xml                    # Parent POM
├── db/
│   ├── schema.cds             # Domain model
│   └── data/                  # CSV test data
│       └── my.bookshop-Books.csv
├── srv/
│   ├── pom.xml                # Service module POM
│   ├── src/main/
│   │   ├── resources/
│   │   │   ├── application.yaml
│   │   │   └── edmx/          # Generated OData metadata
│   │   └── java/
│   │       └── com/sap/demo/
│   │           ├── Application.java
│   │           └── handlers/
│   │               └── CatalogServiceHandler.java
│   └── cat-service.cds        # Service definition
├── app/                        # Fiori UI (optional)
└── mta.yaml                   # BTP deployment descriptor

Key Maven Dependencies

<dependencies>
    <!-- CAP Java SDK -->
    <dependency>
        <groupId>com.sap.cds</groupId>
        <artifactId>cds-starter-spring-boot</artifactId>
    </dependency>
    <!-- OData V4 protocol adapter -->
    <dependency>
        <groupId>com.sap.cds</groupId>
        <artifactId>cds-adapter-odata-v4</artifactId>
    </dependency>
    <!-- HANA database support -->
    <dependency>
        <groupId>com.sap.cds</groupId>
        <artifactId>cds-feature-hana</artifactId>
    </dependency>
    <!-- XSUAA security -->
    <dependency>
        <groupId>com.sap.cds</groupId>
        <artifactId>cds-feature-xsuaa</artifactId>
    </dependency>
</dependencies>

Top 5 Pitfalls

  1. Bypassing the CQN API to use raw JDBC. CAP’s generic runtime (authorization, draft, localization) only works through CQN. Raw SQL bypasses all of it.
  2. Putting business logic in @After instead of @On. @After runs after the database operation. For validation or transformation before persistence, use @Before.
  3. Not understanding that @On replaces the generic handler. If you register an @On handler for READ, the built-in CRUD handler won’t run — you must handle the full query yourself or delegate to db.run().
  4. Ignoring the CDS model as the single source of truth. Don’t define database tables manually. Let CDS generate HDI artifacts, EDMX metadata, and typed interfaces from one model.
  5. Using Spring Data repositories alongside CAP persistence. CAP has its own persistence layer. Mixing Spring Data JPA with CAP causes confusion about which layer handles transactions, caching, and authorization.

What to Learn Next

  • Lesson 3.2: CAP Security — @requires, @restrict, XSUAA integration
  • Lesson 3.3: CAP Remote Services — connecting to S/4HANA and external APIs
  • Lesson 3.4: CAP Advanced Patterns — draft, localization, deployment topologies
  • Lesson 3.5: CAP Fiori Elements — annotations for automatic UI generation

CAP Security & Authorization — XSUAA, @restrict & Multi-Tenant Auth

Lesson 3.2 — CAP Security, Authorization & Authentication

Table of Contents

  • 1. Authentication Architecture
  • 2. CDS-Based Authorization
  • 3. Programmatic Authorization
  • 4. Multi-Tenant Security
  • 5. Testing with Mock Users
  • Top 5 Pitfalls
  • What to Learn Next

Summary: CAP Java integrates with SAP XSUAA and IAS for authentication and provides a declarative authorization model via CDS annotations (@requires, @restrict). This lesson covers the authentication flow, CDS-based authorization, instance-based authorization, programmatic security, multi-tenant security concerns, and testing with mock users.

1. Authentication Architecture

Authentication Flow

Browser/Client          Approuter           XSUAA              CAP Java
     │                     │                  │                    │
     │  GET /app           │                  │                    │
     │────────────────────→│                  │                    │
     │                     │ redirect to login│                    │
     │←────────────────────│                  │                    │
     │                     │                  │                    │
     │  Login credentials  │                  │                    │
     │─────────────────────┼─────────────────→│                    │
     │                     │                  │ Authenticate       │
     │                     │                  │ Issue JWT           │
     │  JWT token          │                  │                    │
     │←────────────────────┼──────────────────│                    │
     │                     │                  │                    │
     │  GET /odata/v4/catalog/Books           │                    │
     │  Authorization: Bearer <JWT>           │                    │
     │────────────────────────────────────────┼───────────────────→│
     │                     │                  │                    │
     │                     │                  │    Validate JWT    │
     │                     │                  │    Extract roles   │
     │                     │                  │    Check @restrict │
     │                     │                  │                    │
     │  200 OK + data      │                  │                    │
     │←───────────────────────────────────────┼────────────────────│

JWT Claims Used by CAP

{
  "sub": "user123",
  "email": "[email protected]",
  "scope": [
    "my-app!t12345.Viewer",
    "my-app!t12345.Admin"
  ],
  "xs.user.attributes": {
    "Country": ["DE", "US"],
    "CostCenter": ["CC100"]
  },
  "zid": "tenant-zone-id",
  "grant_type": "authorization_code"
}

CAP extracts from JWT:

  • Roles — from scope claim (mapped via role templates)
  • User attributes — from xs.user.attributes (for instance-based auth)
  • Tenant — from zid claim (for multi-tenancy)
  • User ID — from sub or user_name claim

Configuration

# application.yaml
cds:
  security:
    authentication:
      mode: jwt          # jwt (production) or dummy (development)
    mock:
      users:
        alice:
          password: alice
          roles: [Admin, Viewer]
          attributes:
            Country: [DE]
        bob:
          password: bob
          roles: [Viewer]
          attributes:
            Country: [US]

2. CDS-Based Authorization

@requires — Role-Based Access to Entire Services/Entities

// Require authentication for the entire service
service CatalogService @(requires: 'authenticated-user') {

    // Public read, but require Viewer role
    @requires: 'Viewer'
    entity Books as projection on db.Books;

    // Only Admin can manage authors
    @requires: 'Admin'
    entity Authors as projection on db.Authors;
}

// Admin-only service
service AdminService @(requires: 'Admin') {
    entity Config as projection on db.AppConfig;
}

@restrict — Fine-Grained Operation-Level Authorization

service CatalogService {

    // Different roles for different operations
    @restrict: [
        { grant: 'READ',   to: 'Viewer' },
        { grant: 'WRITE',  to: 'Editor' },
        { grant: '*',      to: 'Admin'  }
    ]
    entity Books as projection on db.Books;

    // Instance-based: users can only see their own orders
    @restrict: [
        { grant: 'READ', to: 'Customer', where: 'createdBy = $user' },
        { grant: '*',    to: 'Admin' }
    ]
    entity Orders as projection on db.Orders;

    // Attribute-based: users see data from their country only
    @restrict: [
        { grant: 'READ', to: 'Viewer', where: 'country = $user.Country' },
        { grant: '*',    to: 'Admin' }
    ]
    entity Suppliers as projection on db.Suppliers;
}

Pseudo-Variables in @restrict

Variable Meaning Source
$user Current user ID JWT sub claim
$user.<attr> User attribute value JWT xs.user.attributes
$now Current timestamp Server clock
$unrestricted Bypass all restrictions Used in privileged service calls

xs-security.json Mapping

{
  "xsappname": "my-app",
  "tenant-mode": "dedicated",
  "scopes": [
    { "name": "$XSAPPNAME.Viewer", "description": "Read access" },
    { "name": "$XSAPPNAME.Editor", "description": "Write access" },
    { "name": "$XSAPPNAME.Admin",  "description": "Full access" }
  ],
  "attributes": [
    { "name": "Country", "description": "Country filter", "valueType": "string" },
    { "name": "CostCenter", "description": "Cost center", "valueType": "string" }
  ],
  "role-templates": [
    {
      "name": "Viewer",
      "scope-references": ["$XSAPPNAME.Viewer"],
      "attribute-references": ["Country"]
    },
    {
      "name": "Editor",
      "scope-references": ["$XSAPPNAME.Editor", "$XSAPPNAME.Viewer"],
      "attribute-references": ["Country"]
    },
    {
      "name": "Admin",
      "scope-references": ["$XSAPPNAME.Admin", "$XSAPPNAME.Editor", "$XSAPPNAME.Viewer"]
    }
  ]
}

3. Programmatic Authorization

Accessing User Info in Handlers

@Component
@ServiceName(CatalogService_.CDS_NAME)
public class CatalogServiceHandler implements EventHandler {

    @Before(event = CqnService.EVENT_CREATE, entity = Orders_.CDS_NAME)
    public void enrichOrder(CdsCreateEventContext context) {
        UserInfo user = context.getUserInfo();

        // Get user ID
        String userId = user.getName();

        // Check roles
        boolean isAdmin = user.hasRole("Admin");

        // Get user attributes
        List<String> countries = user.getAttributeValues("Country");

        // Set audit fields
        context.getCqn().entries().forEach(order -> {
            order.put("createdBy", userId);
            order.put("createdAt", Instant.now());
        });
    }

    @Before(event = CqnService.EVENT_UPDATE, entity = Orders_.CDS_NAME)
    public void checkOwnership(CdsUpdateEventContext context) {
        UserInfo user = context.getUserInfo();
        if (user.hasRole("Admin")) return; // Admin can update anything

        // Check if user owns the order
        String orderId = context.getCqn().entries().get(0).get("ID").toString();
        CqnSelect check = Select.from(Orders_.class)
            .where(o -> o.ID().eq(orderId).and(o.createdBy().eq(user.getName())));

        if (db.run(check).rowCount() == 0) {
            throw new ServiceException(ErrorStatuses.FORBIDDEN,
                "You can only update your own orders");
        }
    }
}

Privileged (Technical) Access

For service-to-service calls or background jobs that need to bypass authorization:

@Autowired
private CqnService catalogService;

public void backgroundJob() {
    // Run with system/privileged user — bypasses @restrict
    RequestContext privileged = RequestContext.CDS_SYSTEM_USER;

    catalogService.run(privileged, ctx -> {
        CqnSelect allOrders = Select.from(Orders_.class);
        return db.run(allOrders);
    });
}

4. Multi-Tenant Security

Tenant Isolation

In multi-tenant applications, CAP automatically:

  1. Extracts tenant ID from JWT zid claim
  2. Routes database queries to the correct HDI container
  3. Prevents cross-tenant data access
@Before(event = "*")
public void logTenantAccess(EventContext context) {
    String tenant = context.getUserInfo().getTenant();
    log.info("Request from tenant: {}", tenant);
    // CAP automatically routes to correct HDI container
    // No manual tenant filtering needed!
}

Cross-Tenant API Calls

If a provider-side job needs to access tenant data:

public void processTenant(String tenantId) {
    RequestContext tenantContext = RequestContext.of(b -> {
        b.tenant(tenantId);
        b.user(SystemUser.of(tenantId));
    });

    service.run(tenantContext, ctx -> {
        // This runs in the context of the specified tenant
        return db.run(Select.from(Orders_.class));
    });
}

5. Testing with Mock Users

Integration Test Setup

@SpringBootTest
@AutoConfigureMockMvc
class CatalogServiceAuthTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithMockUser(username = "alice", roles = {"Viewer"})
    void viewerCanReadBooks() throws Exception {
        mockMvc.perform(get("/odata/v4/catalog/Books"))
            .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(username = "bob", roles = {"Viewer"})
    void viewerCannotCreateBooks() throws Exception {
        mockMvc.perform(post("/odata/v4/catalog/Books")
                .contentType("application/json")
                .content("{"title":"New Book","price":29.99}"))
            .andExpect(status().isForbidden());
    }

    @Test
    void unauthenticatedCannotAccess() throws Exception {
        mockMvc.perform(get("/odata/v4/catalog/Books"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(username = "admin", roles = {"Admin"})
    void adminCanCreateBooks() throws Exception {
        mockMvc.perform(post("/odata/v4/catalog/Books")
                .contentType("application/json")
                .content("{"title":"New Book","price":29.99}"))
            .andExpect(status().isCreated());
    }
}

Mock Users in application.yaml (Dev Profile)

spring:
  profiles: default  # local development only
cds:
  security:
    authentication:
      mode: dummy     # accepts any credentials
    mock:
      users:
        admin:
          password: admin
          roles: [Admin, Editor, Viewer]
          attributes:
            Country: [DE, US]
        viewer:
          password: viewer
          roles: [Viewer]
          attributes:
            Country: [DE]

Top 5 Pitfalls

  1. Using authentication.mode: dummy in production. This disables JWT validation entirely. Always use jwt mode in production and ensure XSUAA binding exists.
  2. Confusing scopes and roles. XSUAA scopes are technical (my-app!t12345.Viewer). Role templates map scopes to business roles (Viewer). CDS @requires uses role template names.
  3. Forgetting instance-based authorization for user-specific data. @requires: 'Viewer' grants access to ALL entities of that type. Use @restrict with where clauses for row-level filtering.
  4. Not testing authorization in integration tests. Unit tests often skip security. Use @WithMockUser or CAP’s mock user configuration to test authorization rules.
  5. Hardcoding tenant checks in handlers. CAP handles tenant isolation automatically via the persistence layer. Manual WHERE tenant_id = ? filters are unnecessary and error-prone.

What to Learn Next

  • Lesson 1.4: Multi-Tenancy — XSUAA identity zones and tenant-mode
  • Lesson 3.1: CAP Architecture — handler model and event processing
  • Lesson 3.3: CAP Remote Services — securing service-to-service communication
  • Lesson 1.1: BTP Architecture — XSUAA and trust configuration

CAP Remote Services & Mashups — Consuming External APIs

Lesson 3.3 — CAP Remote Services & Mashups

Table of Contents

  • 1. Remote Service Architecture
  • 2. Importing External Service Definitions
  • 3. Destination Configuration
  • 4. Consuming Remote Services in Handlers
  • 5. Error Handling & Resilience
  • Top 5 Pitfalls
  • What to Learn Next

Summary: CAP Java supports integrating external services — S/4HANA OData APIs, REST APIs, and other CAP services — as “remote services” defined in CDS. This enables mashup scenarios where your application combines local data with remote data, projecting external entities alongside your own. This lesson covers importing external APIs, the Destination Service, remote service consumption, mashup patterns, and error handling.

1. Remote Service Architecture

┌────────────────────────────────────────────────────────────┐
│                  CAP Java Application                       │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  CDS Model                                           │   │
│  │  ┌──────────────┐  ┌──────────────────────────────┐  │   │
│  │  │ Local Entities│  │ External Service (imported)   │  │   │
│  │  │ (Books,      │  │ (API_BUSINESS_PARTNER)       │  │   │
│  │  │  Orders)     │  │                              │  │   │
│  │  └──────┬───────┘  └──────────────┬───────────────┘  │   │
│  │         │                         │                   │   │
│  │  ┌──────▼─────────────────────────▼───────────────┐   │   │
│  │  │  Mashup Service                                 │   │   │
│  │  │  (combines local + remote in one projection)    │   │   │
│  │  └──────────────────────┬──────────────────────────┘   │   │
│  └─────────────────────────┼──────────────────────────────┘   │
│                            │                                   │
│  ┌─────────────────────────▼──────────────────────────────┐   │
│  │  CAP Runtime                                            │   │
│  │  ┌──────────────────┐  ┌────────────────────────────┐  │   │
│  │  │ Local DB (HANA)  │  │ Remote HTTP Client          │  │   │
│  │  │ via CQN → SQL    │  │ via Destination Service     │  │   │
│  │  └──────────────────┘  └─────────────┬──────────────┘  │   │
│  └──────────────────────────────────────┼─────────────────┘   │
└─────────────────────────────────────────┼─────────────────────┘
                                          │
                          ┌───────────────▼───────────────┐
                          │  BTP Destination Service       │
                          │  ┌─────────────────────────┐   │
                          │  │ S4HANA_DEST              │   │
                          │  │  URL: https://s4.sap.com │   │
                          │  │  Auth: OAuth2SAMLBearer  │   │
                          │  └─────────────────────────┘   │
                          └───────────────┬───────────────┘
                                          │
                          ┌───────────────▼───────────────┐
                          │  S/4HANA Cloud                 │
                          │  /sap/opu/odata/sap/           │
                          │  API_BUSINESS_PARTNER          │
                          └───────────────────────────────┘

2. Importing External Service Definitions

Step 1: Download the EDMX

From the SAP Business Accelerator Hub or your S/4HANA system:

# Download API_BUSINESS_PARTNER EDMX
curl -o srv/external/API_BUSINESS_PARTNER.edmx 
  "https://api.sap.com/api/API_BUSINESS_PARTNER/resource/edmx"

Step 2: Import in CDS

// srv/external/API_BUSINESS_PARTNER.cds (auto-generated from EDMX)
// Run: cds import srv/external/API_BUSINESS_PARTNER.edmx

@cds.external
service API_BUSINESS_PARTNER {
    @cds.persistence.skip
    entity A_BusinessPartner {
        key BusinessPartner         : String(10);
        BusinessPartnerFullName     : String(81);
        FirstName                   : String(40);
        LastName                    : String(40);
        BusinessPartnerCategory     : String(1);
        to_BusinessPartnerAddress   : Composition of many A_BusinessPartnerAddress;
    }

    @cds.persistence.skip
    entity A_BusinessPartnerAddress {
        key BusinessPartner : String(10);
        key AddressID       : String(10);
        Country             : String(3);
        CityName            : String(40);
        StreetName          : String(60);
        PostalCode          : String(10);
    }
}

Step 3: Reference in Your Service

using { API_BUSINESS_PARTNER as s4bp } from './external/API_BUSINESS_PARTNER';
using { my.bookshop as db } from '../db/schema';

service MashupService {
    // Local entity
    entity Orders as projection on db.Orders;

    // Remote entity (projected from imported service)
    @readonly
    entity BusinessPartners as projection on s4bp.A_BusinessPartner {
        key BusinessPartner,
        BusinessPartnerFullName,
        FirstName,
        LastName
    };
}

3. Destination Configuration

BTP Destination Setup

Destination Name: S4HANA_BP
URL:              https://my-s4hana.s4hana.ondemand.com
Authentication:   OAuth2SAMLBearerAssertion
Audience:         https://my-s4hana.s4hana.ondemand.com
Token URL:        https://my-s4hana.s4hana.ondemand.com/sap/bc/sec/oauth2/token
Client ID:        <communication arrangement client>
Client Secret:    <communication arrangement secret>

CAP Configuration for Remote Services

# application.yaml
cds:
  remote:
    services:
      API_BUSINESS_PARTNER:
        destination:
          name: S4HANA_BP
          type: odata-v2      # S/4HANA uses OData V2
          suffix: /sap/opu/odata/sap/API_BUSINESS_PARTNER

Or via package.json / .cdsrc.json:

{
  "cds": {
    "requires": {
      "API_BUSINESS_PARTNER": {
        "kind": "odata-v2",
        "model": "srv/external/API_BUSINESS_PARTNER",
        "[production]": {
          "credentials": {
            "destination": "S4HANA_BP",
            "path": "/sap/opu/odata/sap/API_BUSINESS_PARTNER"
          }
        }
      }
    }
  }
}

4. Consuming Remote Services in Handlers

Basic Remote Service Call

@Component
@ServiceName(MashupService_.CDS_NAME)
public class MashupServiceHandler implements EventHandler {

    @Autowired
    @Qualifier(API_BUSINESS_PARTNER_.CDS_NAME)
    private CqnService bpService;  // Remote service (injected by CAP)

    @On(event = CqnService.EVENT_READ, entity = "MashupService.BusinessPartners")
    public void readBusinessPartners(CdsReadEventContext context) {
        // CAP translates CQN to OData query and sends to S/4HANA
        CqnSelect query = context.getCqn();
        Result result = bpService.run(query);
        context.setResult(result);
    }
}

Mashup: Combine Local + Remote Data

@After(event = CqnService.EVENT_READ, entity = Orders_.CDS_NAME)
public void enrichOrdersWithBPData(CdsReadEventContext context) {
    List<Map<String, Object>> orders = context.getResult().listOf(HashMap.class);

    // Collect unique BP IDs
    Set<String> bpIds = orders.stream()
        .map(o -> (String) o.get("customerId"))
        .filter(Objects::nonNull)
        .collect(Collectors.toSet());

    if (bpIds.isEmpty()) return;

    // Batch fetch from S/4HANA
    CqnSelect bpQuery = Select.from(A_BusinessPartner_.class)
        .columns(bp -> bp.BusinessPartner(), bp -> bp.BusinessPartnerFullName())
        .where(bp -> bp.BusinessPartner().in(bpIds));

    Map<String, String> bpNames = bpService.run(bpQuery)
        .streamOf(A_BusinessPartner_.class)
        .collect(Collectors.toMap(
            A_BusinessPartner::getBusinessPartner,
            A_BusinessPartner::getBusinessPartnerFullName
        ));

    // Enrich local orders with remote BP names
    orders.forEach(order -> {
        String bpId = (String) order.get("customerId");
        order.put("customerName", bpNames.getOrDefault(bpId, "Unknown"));
    });
}

Custom Action: Create in Remote System

@On(event = "createBusinessPartner")
public void createBP(CreateBusinessPartnerContext context) {
    Map<String, Object> bpData = new HashMap<>();
    bpData.put("BusinessPartnerCategory", "1"); // Person
    bpData.put("FirstName", context.getFirstName());
    bpData.put("LastName", context.getLastName());

    CqnInsert insert = Insert.into(A_BusinessPartner_.class).entry(bpData);

    try {
        Result result = bpService.run(insert);
        context.setResult(result.single(A_BusinessPartner_.class));
    } catch (ServiceException e) {
        throw new ServiceException(ErrorStatuses.BAD_GATEWAY,
            "Failed to create BP in S/4HANA: " + e.getMessage(), e);
    }
}

5. Error Handling & Resilience

Timeout Configuration

cds:
  remote:
    services:
      API_BUSINESS_PARTNER:
        http:
          timeout: 30000       # 30 seconds
          connectionTimeout: 5000  # 5 seconds

Error Handling Pattern

@On(event = CqnService.EVENT_READ, entity = "MashupService.BusinessPartners")
public void readBPWithFallback(CdsReadEventContext context) {
    try {
        Result result = bpService.run(context.getCqn());
        context.setResult(result);
    } catch (ServiceException e) {
        if (isTransientError(e)) {
            // Retry once
            try {
                Thread.sleep(1000);
                Result result = bpService.run(context.getCqn());
                context.setResult(result);
            } catch (Exception retryEx) {
                log.error("Retry failed for BP read", retryEx);
                throw new ServiceException(ErrorStatuses.BAD_GATEWAY,
                    "S/4HANA temporarily unavailable");
            }
        } else {
            throw new ServiceException(ErrorStatuses.BAD_GATEWAY,
                "Failed to read from S/4HANA: " + e.getMessage());
        }
    }
}

private boolean isTransientError(ServiceException e) {
    int status = e.getErrorStatus().getCode();
    return status == 503 || status == 429 || status == 408;
}

Caching Remote Data

@Autowired
private CacheManager cacheManager;

@Cacheable(value = "businessPartners", key = "#bpId", unless = "#result == null")
public A_BusinessPartner getCachedBP(String bpId) {
    CqnSelect query = Select.from(A_BusinessPartner_.class).byId(bpId);
    return bpService.run(query).single(A_BusinessPartner_.class);
}
spring:
  cache:
    caffeine:
      spec: maximumSize=500,expireAfterWrite=300s  # 5 min TTL

Top 5 Pitfalls

  1. Not using the Destination Service. Hardcoding S/4HANA URLs and credentials in your app is a security risk and breaks environment portability. Always use BTP Destinations.
  2. Fetching too much data from remote systems. CQN queries on remote services translate to OData $filter, $select, $top. If you fetch all BPs without filters, you’ll time out. Always project and filter.
  3. Ignoring OData version mismatch. S/4HANA uses OData V2. CAP’s remote service client handles translation, but you must configure type: odata-v2 correctly.
  4. No error handling for remote calls. Remote systems go down. Without try/catch, a single S/4HANA timeout will return 500 to your users instead of a graceful degradation.
  5. N+1 query problem with mashups. Fetching BP data for each order individually creates N remote calls. Always batch-fetch remote data using IN filters.

What to Learn Next

  • Lesson 3.1: CAP Architecture — handler model and CQN queries
  • Lesson 3.4: CAP Advanced Patterns — deployment topologies, caching strategies
  • Lesson 2.4: Kyma Eventing — event-driven integration alternative to synchronous API calls
  • Lesson 1.3: Event Mesh — asynchronous data synchronization patterns

Kyma Eventing & S/4HANA Extension Patterns

Lesson 2.4 — Kyma Eventing & Extension Patterns

Table of Contents

  • 1. Side-by-Side Extension Model
  • 2. S/4HANA Event Integration
  • 3. Extension Patterns
  • 4. Resilience Patterns
  • 5. Event Processing Best Practices
  • Top 5 Pitfalls
  • What to Learn Next

Summary: SAP’s recommended approach for extending S/4HANA and other SAP systems is “side-by-side” extensions on BTP — keeping the clean core clean while building custom logic externally. Kyma provides the eventing infrastructure and runtime for these extensions. This lesson covers the side-by-side extension model, event-driven architecture patterns, S/4HANA event integration, and production-ready extension design.

1. Side-by-Side Extension Model

Clean Core Principle

┌──────────────────────────────────┐     ┌──────────────────────────────────┐
│        S/4HANA (Clean Core)       │     │     Kyma Runtime (Extensions)     │
│                                   │     │                                   │
│  ┌──────────────────────────────┐ │     │  ┌──────────────────────────────┐ │
│  │  Standard Business Logic     │ │     │  │  Custom Logic                │ │
│  │  (no modifications)          │ │     │  │  - Validations               │ │
│  └──────────────────────────────┘ │     │  │  - Enrichments               │ │
│                                   │     │  │  - Integrations              │ │
│  ┌──────────────────────────────┐ │     │  │  - Notifications             │ │
│  │  Released APIs (stable)      │─┼─────┼─→│  - Custom workflows          │ │
│  └──────────────────────────────┘ │     │  └──────────────────────────────┘ │
│                                   │     │                                   │
│  ┌──────────────────────────────┐ │     │  ┌──────────────────────────────┐ │
│  │  Business Events             │─┼─────┼─→│  Event Subscribers           │ │
│  │  (BP Changed, SO Created...) │ │     │  │  (Functions / Microservices) │ │
│  └──────────────────────────────┘ │     │  └──────────────────────────────┘ │
└──────────────────────────────────┘     └──────────────────────────────────┘
              │                                          │
              └──────────── Event Mesh ──────────────────┘

Why side-by-side?

  • S/4HANA upgrades are not blocked by custom code
  • Extensions can be developed, tested, and deployed independently
  • Different lifecycle: extensions can iterate faster than core ERP
  • Clear API boundaries via Released APIs and Business Events

2. S/4HANA Event Integration

Event Flow: S/4HANA → Kyma

S/4HANA Cloud              SAP Event Mesh           Kyma Runtime
     │                          │                        │
     │ BP Changed event         │                        │
     │ (RAP Business Event)     │                        │
     │─────────────────────────→│                        │
     │                          │ CloudEvent format      │
     │                          │────────────────────────→│
     │                          │                        │ Subscription match
     │                          │                        │ → deliver to sink
     │                          │                        │
     │                          │                        │ ┌─────────────────┐
     │                          │                        │ │ Event Handler   │
     │                          │                        │ │ (Function/Pod)  │
     │                          │                        │ └────────┬────────┘
     │                          │                        │          │
     │                          │                        │   Call S/4 API
     │←─────────────────────────┼────────────────────────┼──────────┘
     │  GET /API_BUSINESS_PARTNER│                       │
     │  (Released API)           │                        │

Common S/4HANA Business Events

Event Type Description Use Case
sap.s4.beh.businesspartner.changed.v1 BP master data changed Sync to CRM/MDM
sap.s4.beh.businesspartner.created.v1 New BP created Trigger onboarding
sap.s4.beh.salesorder.created.v1 Sales order created Fulfillment workflow
sap.s4.beh.salesorder.changed.v1 Sales order modified Update downstream
sap.s4.beh.purchaseorder.created.v1 Purchase order created Approval workflow
sap.s4.beh.product.changed.v1 Product master changed Catalog sync

Event Payload (CloudEvents Format)

{
  "specversion": "1.0",
  "type": "sap.s4.beh.businesspartner.changed.v1",
  "source": "/default/sap.s4.beh/XXXXXXXXXX",
  "id": "ABY+LHK...",
  "time": "2024-03-15T14:30:00Z",
  "datacontenttype": "application/json",
  "data": {
    "BusinessPartner": "1000042"
  }
}

Important: S/4HANA events carry minimal data (usually just the key). You must call back to S/4HANA APIs to get the full entity data.

3. Extension Patterns

Pattern 1: Event-Driven Data Sync

Sync business partner changes from S/4HANA to an external CRM:

@Component
@ServiceName("EventProcessorService")
public class BPSyncHandler implements EventHandler {

    @Autowired
    private S4HanaClient s4Client;

    @Autowired
    private CrmClient crmClient;

    @On(event = "sap.s4.beh.businesspartner.changed.v1")
    public void onBPChanged(EventContext context) {
        String bpId = context.get("data").get("BusinessPartner").asText();

        // 1. Fetch full BP data from S/4HANA (Released API)
        BusinessPartner bp = s4Client.getBusinessPartner(bpId);

        // 2. Transform to CRM format
        CrmContact contact = CrmContact.builder()
            .externalId(bp.getBusinessPartner())
            .name(bp.getBusinessPartnerFullName())
            .email(bp.getEmailAddress())
            .build();

        // 3. Upsert in CRM
        crmClient.upsertContact(contact);

        log.info("Synced BP {} to CRM", bpId);
    }
}

Pattern 2: Validation Extension (Pre-Check via API)

Validate business rules before S/4HANA processing using a custom API:

Fiori App → Custom Validation API (Kyma) → S/4HANA API
                     │
                     ├── Check credit limit (external service)
                     ├── Check sanctions list
                     └── Return allow/deny
@On(event = CdsService.EVENT_CREATE, entity = "OrderValidation")
public void validateOrder(CdsCreateEventContext context) {
    CdsData order = context.getCqn().entries().get(0);

    String customerId = order.get("customerId").toString();
    BigDecimal amount = (BigDecimal) order.get("totalAmount");

    // External credit check
    CreditCheckResult credit = creditService.check(customerId, amount);
    if (!credit.isApproved()) {
        throw new ServiceException(ErrorStatuses.FORBIDDEN, 
            "Credit limit exceeded for customer " + customerId);
    }

    // Sanctions screening
    boolean sanctioned = sanctionsService.screen(customerId);
    if (sanctioned) {
        throw new ServiceException(ErrorStatuses.FORBIDDEN,
            "Customer is on sanctions list");
    }

    context.setResult(Map.of("approved", true, "creditScore", credit.getScore()));
}

Pattern 3: Notification Extension

Send notifications when key business events occur:

// Kyma Function: notify-on-large-order
module.exports = {
  main: async function (event, context) {
    const orderData = JSON.parse(event.data);
    const orderId = orderData.SalesOrder;

    // Fetch full order from S/4HANA
    const order = await fetchOrder(orderId);

    if (order.TotalNetAmount > 100000) {
      // Send Teams notification
      await sendTeamsNotification({
        title: `Large Order Created: ${orderId}`,
        text: `Customer: ${order.SoldToParty}nAmount: ${order.TotalNetAmount} ${order.TransactionCurrency}`,
        urgency: "high"
      });

      // Send email to sales manager
      await sendEmail({
        to: order.SalesManagerEmail,
        subject: `Action Required: Large Order ${orderId}`,
        body: `A sales order exceeding 100K has been created...`
      });
    }

    return { statusCode: 200 };
  }
};

Pattern 4: Saga / Orchestration

Coordinate multiple services with compensation on failure:

Order Created Event
      │
      ▼
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│ 1. Reserve       │───→│ 2. Process       │───→│ 3. Send          │
│    Inventory     │    │    Payment       │    │    Confirmation  │
│    (S/4HANA)     │    │    (Stripe)      │    │    (Email)       │
└────────┬────────┘    └────────┬────────┘    └─────────────────┘
         │ failure              │ failure
         ▼                      ▼
┌─────────────────┐    ┌─────────────────┐
│ Compensate:      │    │ Compensate:      │
│ Release inventory│    │ Release inventory│
│                  │    │ Refund payment   │
└─────────────────┘    └─────────────────┘

4. Resilience Patterns

Retry with Exponential Backoff

apiVersion: eventing.kyma-project.io/v1alpha2
kind: Subscription
metadata:
  name: bp-sync-sub
spec:
  source: ""
  types:
    - sap.s4.beh.businesspartner.changed.v1
  sink: http://bp-sync-service.my-app.svc.cluster.local
  config:
    maxInFlightMessages: "10"

In the handler, implement retry logic:

@Retryable(
    value = {S4HanaException.class, HttpTimeoutException.class},
    maxAttempts = 3,
    backoff = @Backoff(delay = 1000, multiplier = 2.0)
)
public BusinessPartner fetchBPWithRetry(String bpId) {
    return s4Client.getBusinessPartner(bpId);
}

Dead Letter Queue Pattern

module.exports = {
  main: async function (event, context) {
    try {
      await processEvent(event);
    } catch (error) {
      // After max retries, send to dead letter topic
      if (getRetryCount(event) >= MAX_RETRIES) {
        await publishToDeadLetter(event, error);
        return { statusCode: 200 }; // ACK to prevent redelivery
      }
      // Return error to trigger redelivery
      return { statusCode: 500, body: { error: error.message } };
    }
  }
};

Circuit Breaker

@CircuitBreaker(name = "s4hana", fallbackMethod = "s4Fallback")
public BusinessPartner getBusinessPartner(String id) {
    return s4HanaApi.getByKey(id);
}

public BusinessPartner s4Fallback(String id, Exception ex) {
    log.warn("S/4HANA circuit open, using cached data for BP {}", id);
    return cache.get("bp:" + id);
}

5. Event Processing Best Practices

Idempotency

Events may be delivered more than once. Always design idempotent handlers:

@On(event = "sap.s4.beh.businesspartner.changed.v1")
public void onBPChanged(EventContext context) {
    String eventId = context.get("id").asText();

    // Check if already processed
    if (eventStore.exists(eventId)) {
        log.info("Event {} already processed, skipping", eventId);
        return;
    }

    // Process event
    processBusinessPartnerChange(context);

    // Mark as processed
    eventStore.save(eventId);
}

Event Ordering

Events from the same source may arrive out of order. Use timestamps or version numbers:

public void syncBusinessPartner(BusinessPartner incoming) {
    BusinessPartner existing = repository.findById(incoming.getId());

    if (existing != null && existing.getLastChanged().isAfter(incoming.getLastChanged())) {
        log.warn("Stale event for BP {}, skipping", incoming.getId());
        return;
    }

    repository.save(incoming);
}

Top 5 Pitfalls

  1. Treating events as commands. Events report what happened (“BP changed”), not what to do. Don’t assume the event payload has all the data — call back to APIs for details.
  2. Not implementing idempotency. Events can be delivered multiple times. Without idempotent handlers, you’ll get duplicate processing.
  3. Tight coupling to S/4HANA availability. If your event handler synchronously calls S/4HANA and it’s down, you lose events. Use retry + DLQ patterns.
  4. Ignoring event ordering. Two rapid changes to the same BP may arrive in reverse order. Use timestamps to detect stale events.
  5. Building a monolithic extension. Split extensions by bounded context (one service for BP sync, another for order validation). Each should have its own subscriptions and lifecycle.

What to Learn Next

  • Lesson 1.3: Event Mesh — deep dive into messaging infrastructure
  • Lesson 2.1: Kyma Architecture — eventing module internals
  • Lesson 2.3: Serverless Functions — lightweight event handlers
  • Lesson 3.3: CAP Remote Services — calling S/4HANA APIs from CAP

Kyma Runtime Architecture — Kubernetes + Istio + SAP Eventing

Lesson 2.1 — Kyma Architecture & Core Components

Table of Contents

  • 1. Kyma vs Vanilla Kubernetes
  • 2. Kyma Cluster Architecture
  • 3. Istio Service Mesh in Kyma
  • 4. Kyma API Gateway
  • 5. Kyma Eventing
  • Top 5 Pitfalls
  • What to Learn Next

Summary: SAP Kyma Runtime is a managed Kubernetes offering on BTP that adds an opinionated layer of components — Istio service mesh, NATS-based eventing, API Gateway, and the BTP Operator — on top of a standard K8s cluster. This lesson covers what Kyma adds versus vanilla Kubernetes, the modular architecture, Istio internals, API Gateway configuration, and eventing infrastructure.

1. Kyma vs Vanilla Kubernetes

What Kyma Adds

┌──────────────────────────────────────────────────────────┐
│                    Kyma Runtime                           │
│                                                          │
│  ┌────────────────────────────────────────────────────┐  │
│  │  SAP-Managed Layer                                  │  │
│  │  ┌──────────┐ ┌──────────┐ ┌───────────────────┐   │  │
│  │  │ Istio    │ │ Kyma API │ │ BTP Operator      │   │  │
│  │  │ Service  │ │ Gateway  │ │ (ServiceInstance/  │   │  │
│  │  │ Mesh     │ │ (APIRule)│ │  ServiceBinding)   │   │  │
│  │  └──────────┘ └──────────┘ └───────────────────┘   │  │
│  │  ┌──────────┐ ┌──────────┐ ┌───────────────────┐   │  │
│  │  │ NATS     │ │ Serverless│ │ Lifecycle Manager │   │  │
│  │  │ Eventing │ │ Functions │ │ (module install)  │   │  │
│  │  └──────────┘ └──────────┘ └───────────────────┘   │  │
│  └────────────────────────────────────────────────────┘  │
│                                                          │
│  ┌────────────────────────────────────────────────────┐  │
│  │  Standard Kubernetes                                │  │
│  │  Pods, Deployments, Services, ConfigMaps, Secrets   │  │
│  │  Namespaces, RBAC, HPA, PDB, Ingress               │  │
│  └────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────┘
Component Vanilla K8s Kyma
Service mesh Install Istio yourself Pre-installed, SAP-managed Istio
API Gateway Ingress controller (nginx, etc.) APIRule CRD → Istio VirtualService + Oathkeeper
Eventing Install NATS/Kafka yourself Kyma Eventing (NATS or Event Mesh backend)
BTP services Manual credential management BTP Operator: ServiceInstance/ServiceBinding CRDs
Serverless Install Knative/OpenFaaS Kyma Functions (Node.js, Python)
Certificate mgmt cert-manager manual setup Integrated cert management
DNS External-DNS manual setup Managed DNS for *.kyma.ondemand.com

Kyma Modules System

Kyma uses a modular installation model. Modules are optional components enabled via the Kyma CR:

apiVersion: operator.kyma-project.io/v1beta2
kind: Kyma
metadata:
  name: default
  namespace: kyma-system
spec:
  modules:
    - name: istio
    - name: api-gateway
    - name: eventing
    - name: btp-operator
    - name: serverless       # optional
    - name: telemetry        # optional

You can enable/disable modules without affecting the core cluster.

2. Kyma Cluster Architecture

System Namespaces

kyma-system/          — Kyma core components, lifecycle manager
istio-system/         — Istio control plane (istiod, gateways)
eventing-system/      — NATS server, eventing controller
serverless-system/    — Serverless function controller
btp-operator/         — BTP Operator controller
kube-system/          — Standard K8s system components

Rule: Never deploy your workloads into system namespaces. Create dedicated namespaces for your applications.

BTP Operator for Service Consumption

The BTP Operator lets you manage BTP service instances and bindings as Kubernetes resources:

# Create a service instance
apiVersion: services.cloud.sap.com/v1
kind: ServiceInstance
metadata:
  name: my-xsuaa
  namespace: my-app
spec:
  serviceOfferingName: xsuaa
  servicePlanName: application
  parameters:
    xsappname: my-app
    tenant-mode: dedicated
    oauth2-configuration:
      redirect-uris:
        - "https://my-app.kyma.ondemand.com/**"
---
# Create a binding (generates K8s Secret)
apiVersion: services.cloud.sap.com/v1
kind: ServiceBinding
metadata:
  name: my-xsuaa-binding
  namespace: my-app
spec:
  serviceInstanceName: my-xsuaa
  secretName: my-xsuaa-secret

The BTP Operator communicates with the BTP Service Manager API to provision/bind services and stores credentials in Kubernetes Secrets.

3. Istio Service Mesh in Kyma

Sidecar Injection

Istio injects an Envoy proxy sidecar into every pod in labeled namespaces:

apiVersion: v1
kind: Namespace
metadata:
  name: my-app
  labels:
    istio-injection: enabled  # enables automatic sidecar injection

Each pod gets two containers:

Pod: my-service-abc123
├── my-service (your Java app, port 8080)
└── istio-proxy (Envoy sidecar, intercepts all traffic)

Mutual TLS (mTLS)

Istio enables automatic mTLS between all pods in the mesh:

Pod A (my-service)          Pod B (other-service)
┌─────────────────┐        ┌─────────────────┐
│ App container    │        │ App container    │
│ (plain HTTP)     │        │ (plain HTTP)     │
│       │          │        │       ▲          │
│       ▼          │        │       │          │
│ Envoy sidecar ───┼──mTLS──┼─→ Envoy sidecar │
│ (encrypts)       │        │ (decrypts)       │
└─────────────────┘        └─────────────────┘

Your application code uses plain HTTP; Istio handles encryption transparently.

Traffic Management

# VirtualService for traffic splitting (canary deployment)
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: my-service
spec:
  hosts:
    - my-service
  http:
    - route:
        - destination:
            host: my-service
            subset: v1
          weight: 90
        - destination:
            host: my-service
            subset: v2
          weight: 10
---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: my-service
spec:
  host: my-service
  subsets:
    - name: v1
      labels:
        version: v1
    - name: v2
      labels:
        version: v2

4. Kyma API Gateway

APIRule CRD

The APIRule is Kyma’s abstraction for exposing services externally with authentication:

apiVersion: gateway.kyma-project.io/v1beta1
kind: APIRule
metadata:
  name: my-service-api
  namespace: my-app
spec:
  host: my-service.kyma.ondemand.com
  service:
    name: my-service
    port: 8080
  gateway: kyma-system/kyma-gateway
  rules:
    # Public endpoint (no auth)
    - path: /health
      methods: ["GET"]
      accessStrategies:
        - handler: noop
    # JWT-protected endpoint
    - path: /api/.*
      methods: ["GET", "POST", "PUT", "DELETE"]
      accessStrategies:
        - handler: jwt
          config:
            jwks_urls:
              - "https://mysubaccount.authentication.eu10.hana.ondemand.com/token_keys"
            trusted_issuers:
              - "https://mysubaccount.authentication.eu10.hana.ondemand.com"

Under the hood, APIRule creates:

  1. An Istio VirtualService for routing
  2. An Ory Oathkeeper Rule for authentication/authorization

CORS Configuration

spec:
  corsPolicy:
    allowOrigins:
      - exact: "https://my-frontend.example.com"
    allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
    allowHeaders: ["Authorization", "Content-Type"]
    allowCredentials: true

5. Kyma Eventing

NATS vs Event Mesh Backend

Kyma Eventing supports two backends:

Feature NATS (default) Event Mesh
Deployment In-cluster NATS server BTP managed service
Durability In-memory (limited) Persistent queues
S/4HANA integration Via Event Mesh bridge Native
Scale Single cluster Cross-region
Cost Included in Kyma Additional BTP service cost

Subscription CRD

apiVersion: eventing.kyma-project.io/v1alpha2
kind: Subscription
metadata:
  name: bp-changed-sub
  namespace: my-app
spec:
  source: ""
  types:
    - sap.s4.beh.businesspartner.changed.v1
  sink: http://my-service.my-app.svc.cluster.local:8080/events
  config:
    maxInFlightMessages: "10"

The eventing controller ensures events matching the specified types are delivered to the sink URL via HTTP POST with CloudEvents format.

Event Type Versioning

Follow the SAP naming convention:

<prefix>.<domain>.<business-object>.<operation>.v<version>

Examples:
sap.s4.beh.businesspartner.changed.v1
my.company.order.validated.v1
my.company.order.validated.v2  # new version with breaking changes

Top 5 Pitfalls

  1. Forgetting istio-injection: enabled label on namespaces. Without it, pods don’t get sidecars and miss mTLS, traffic management, and observability.
  2. Using Ingress instead of APIRule. Kyma’s API Gateway (APIRule) integrates with Istio and Oathkeeper. Standard K8s Ingress won’t work correctly on Kyma.
  3. Not configuring resource requests/limits. Istio sidecars add ~50MB memory per pod. Account for this in your resource planning.
  4. Assuming NATS eventing is durable. Default NATS backend is in-memory. For production event-driven architectures, use Event Mesh backend.
  5. Deploying into system namespaces. Kyma upgrades may overwrite your resources. Always use dedicated namespaces.

What to Learn Next

  • Lesson 2.2: Deploying Java Microservices on Kyma — Dockerfiles, Helm charts, service bindings
  • Lesson 2.3: Kyma Serverless Functions — when and how to use them
  • Lesson 2.4: Kyma Eventing & Extension Patterns — S/4HANA side-by-side extensions
  • Lesson 1.1: BTP Architecture — how Kyma fits in the BTP landscape

Deploying Java Microservices on SAP Kyma — Helm, Docker & BTP Services

Lesson 2.2 — Deploying Java Microservices on Kyma

Table of Contents

  • 1. Containerizing a Spring Boot / CAP Java Application
  • 2. Helm Chart Structure
  • 3. Service Credential Consumption
  • 4. Health Probes and Graceful Shutdown
  • 5. Deployment Commands
  • Top 5 Pitfalls
  • What to Learn Next

Summary: Deploying Java applications on Kyma requires Docker containerization, Helm chart packaging, BTP service binding via the BTP Operator, and production-hardening with health probes, resource limits, and HPA. This lesson walks through the full deployment lifecycle — from Dockerfile to production-ready Helm chart — with comparisons to Cloud Foundry deployment patterns.

1. Containerizing a Spring Boot / CAP Java Application

Dockerfile (Multi-Stage Build)

# Stage 1: Build
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
COPY srv/ srv/
COPY db/ db/
RUN mvn clean package -DskipTests -B

# Stage 2: Runtime
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app

# Security: run as non-root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

COPY --from=builder /app/srv/target/*.jar app.jar

EXPOSE 8080
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Key Differences from Cloud Foundry

Aspect Cloud Foundry Kyma
Container Buildpack creates it for you You write the Dockerfile
Base image SAP JVM / SapMachine (in buildpack) Your choice (Temurin, SapMachine, etc.)
Memory tuning CF sets -Xmx from memory quota You set via JAVA_OPTS and K8s resource limits
Service credentials VCAP_SERVICES env var Mounted K8s Secrets (files or env vars)
Port binding $PORT env var (assigned by CF) You control (typically 8080)

Building and Pushing the Image

# Build the image
docker build -t my-registry.example.com/my-app:1.0.0 .

# Push to container registry (SAP BTP uses your own registry)
docker push my-registry.example.com/my-app:1.0.0

2. Helm Chart Structure

my-app-chart/
├── Chart.yaml
├── values.yaml
├── templates/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── apirule.yaml
│   ├── service-instance.yaml    # BTP ServiceInstance
│   ├── service-binding.yaml     # BTP ServiceBinding
│   ├── hpa.yaml
│   └── _helpers.tpl

Chart.yaml

apiVersion: v2
name: my-java-app
description: SAP BTP Java microservice on Kyma
type: application
version: 1.0.0
appVersion: "1.0.0"

values.yaml

replicaCount: 2

image:
  repository: my-registry.example.com/my-app
  tag: "1.0.0"
  pullPolicy: IfNotPresent

imagePullSecrets:
  - name: registry-secret

service:
  port: 8080

resources:
  requests:
    memory: "512Mi"
    cpu: "250m"
  limits:
    memory: "1Gi"
    cpu: "1000m"

autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilization: 70

xsuaa:
  serviceOfferingName: xsuaa
  servicePlanName: application
  parameters:
    xsappname: my-app
    tenant-mode: dedicated

hana:
  serviceOfferingName: hana
  servicePlanName: hdi-shared

apiRule:
  host: my-app

Deployment Template

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "my-java-app.fullname" . }}
  labels:
    {{- include "my-java-app.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "my-java-app.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "my-java-app.selectorLabels" . | nindent 8 }}
    spec:
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - containerPort: {{ .Values.service.port }}
              protocol: TCP
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: "cloud"
          volumeMounts:
            - name: xsuaa-secret
              mountPath: /etc/secrets/sapcp/xsuaa/my-xsuaa
              readOnly: true
            - name: hana-secret
              mountPath: /etc/secrets/sapcp/hana/my-hana
              readOnly: true
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: {{ .Values.service.port }}
            initialDelaySeconds: 30
            periodSeconds: 10
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: {{ .Values.service.port }}
            initialDelaySeconds: 20
            periodSeconds: 5
            failureThreshold: 3
          startupProbe:
            httpGet:
              path: /actuator/health/liveness
              port: {{ .Values.service.port }}
            initialDelaySeconds: 10
            periodSeconds: 5
            failureThreshold: 30  # 10s + 30*5s = up to 160s startup time
      volumes:
        - name: xsuaa-secret
          secret:
            secretName: my-xsuaa-secret
        - name: hana-secret
          secret:
            secretName: my-hana-secret

BTP Service Binding Templates

# templates/service-instance.yaml
apiVersion: services.cloud.sap.com/v1
kind: ServiceInstance
metadata:
  name: my-xsuaa
spec:
  serviceOfferingName: {{ .Values.xsuaa.serviceOfferingName }}
  servicePlanName: {{ .Values.xsuaa.servicePlanName }}
  parameters:
    {{- toYaml .Values.xsuaa.parameters | nindent 4 }}
---
apiVersion: services.cloud.sap.com/v1
kind: ServiceBinding
metadata:
  name: my-xsuaa-binding
spec:
  serviceInstanceName: my-xsuaa
  secretName: my-xsuaa-secret

3. Service Credential Consumption

SAP BTP Credential Mounting Convention

SAP libraries (Cloud SDK, spring-xsuaa, CAP) expect credentials at:

/etc/secrets/sapcp/<service-name>/<instance-name>/

The mounted secret files contain JSON credentials. Example file tree:

/etc/secrets/sapcp/xsuaa/my-xsuaa/
├── clientid
├── clientsecret
├── url
├── xsappname
└── ...  (one file per credential key)

Spring Boot Configuration for Kyma

# application-cloud.yaml (activated by SPRING_PROFILES_ACTIVE=cloud)
sap:
  cloud:
    security:
      xsuaa:
        credential-type: binding-secret

CAP Java automatically discovers credentials from mounted secrets when running on Kyma.

4. Health Probes and Graceful Shutdown

Spring Boot Actuator Configuration

# application.yaml
management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus
  endpoint:
    health:
      probes:
        enabled: true
      show-details: always
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true

server:
  shutdown: graceful

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

Graceful Shutdown Flow

1. K8s sends SIGTERM to container
2. K8s removes pod from Service endpoints (no new traffic)
3. Spring Boot stops accepting new requests
4. Spring Boot waits for in-flight requests (up to 30s)
5. Spring Boot closes connections and shuts down
6. K8s waits for terminationGracePeriodSeconds (default 30s)
7. If still running, K8s sends SIGKILL
# In deployment spec
spec:
  terminationGracePeriodSeconds: 45  # > spring shutdown timeout

5. Deployment Commands

# Create namespace with Istio injection
kubectl create namespace my-app
kubectl label namespace my-app istio-injection=enabled

# Create image pull secret
kubectl create secret docker-registry registry-secret 
  --docker-server=my-registry.example.com 
  --docker-username=user 
  --docker-password=password 
  --namespace my-app

# Deploy with Helm
helm install my-app ./my-app-chart 
  --namespace my-app 
  --set image.tag=1.0.0

# Upgrade
helm upgrade my-app ./my-app-chart 
  --namespace my-app 
  --set image.tag=1.1.0

# Check status
kubectl get pods -n my-app
kubectl get servicebindings -n my-app
kubectl get apirules -n my-app

Top 5 Pitfalls

  1. Running as root in containers. Always create a non-root user in your Dockerfile. Kyma may enforce PodSecurityPolicies.
  2. Not setting -XX:MaxRAMPercentage. Without it, the JVM may try to use more memory than the container limit, causing OOMKill.
  3. Missing startup probes for Java apps. Spring Boot can take 30-60s to start. Without startup probes, K8s kills the pod thinking it’s unhealthy.
  4. Hardcoding secrets in values.yaml. Use sealed secrets, external secrets operator, or pass secrets via --set at deploy time.
  5. Forgetting Istio sidecar memory overhead. Each pod gets an Envoy proxy (~50-100MB). Factor this into resource requests.

What to Learn Next

  • Lesson 2.1: Kyma Architecture — understanding the platform components
  • Lesson 2.3: Kyma Serverless Functions — lightweight alternatives to microservices
  • Lesson 4.1: CI/CD — automating Docker builds and Helm deployments
  • Lesson 4.2: Observability — Prometheus metrics, distributed tracing on Kyma

Kyma Serverless Functions — When to Use & How to Build

Lesson 2.3 — Kyma Serverless Functions

Table of Contents

  • 1. Functions vs Microservices: Decision Framework
  • 2. Function CRD
  • 3. Binding BTP Services to Functions
  • 4. Event-Triggered Functions
  • 5. Practical Patterns
  • Top 5 Pitfalls
  • What to Learn Next

Summary: Kyma Serverless Functions provide a lightweight, event-driven compute model for simple tasks — webhook handlers, data transformations, event processors — without the overhead of building Docker images and Helm charts. This lesson covers when to use functions vs microservices, the Function CRD, runtime environments, BTP service binding, and practical patterns.

1. Functions vs Microservices: Decision Framework

Criteria Kyma Function Java Microservice
Complexity Simple logic (< 500 LOC) Complex business logic
Language Node.js, Python Java, Node.js, Go, any
Startup time ~1-3s (cold start) ~15-60s (Spring Boot)
Dependencies npm/pip packages only Full Maven/Gradle ecosystem
Container control None (managed) Full Dockerfile control
Scaling Auto (0 to N, scale-to-zero) HPA (min 1 replica)
State Stateless only Stateless (can use external state)
Testing Limited local testing Full unit/integration testing
Debugging Console logs only Full IDE debugging
Best for Event handlers, webhooks, glue code Business services, APIs, CAP apps

Rule of thumb: If you’d write it as an AWS Lambda, write it as a Kyma Function. If you’d write it as an ECS service, write it as a Kyma microservice.

2. Function CRD

Basic Node.js Function

apiVersion: serverless.kyma-project.io/v1alpha2
kind: Function
metadata:
  name: order-validator
  namespace: my-app
spec:
  runtime: nodejs20
  source:
    inline:
      source: |
        module.exports = {
          main: async function (event, context) {
            const order = JSON.parse(event.data);

            // Validate order
            if (!order.customerId || !order.items?.length) {
              return { statusCode: 400, body: { error: "Invalid order" } };
            }

            // Check total
            const total = order.items.reduce((sum, item) => sum + item.price * item.qty, 0);

            return {
              statusCode: 200,
              body: { 
                valid: true, 
                orderId: order.id, 
                total: total 
              }
            };
          }
        };
      dependencies: |
        {
          "name": "order-validator",
          "version": "1.0.0",
          "dependencies": {}
        }
  scaleConfig:
    minReplicas: 0
    maxReplicas: 5
  resourceConfiguration:
    function:
      resources:
        requests:
          cpu: "50m"
          memory: "64Mi"
        limits:
          cpu: "200m"
          memory: "256Mi"

Python Function

apiVersion: serverless.kyma-project.io/v1alpha2
kind: Function
metadata:
  name: data-transformer
  namespace: my-app
spec:
  runtime: python312
  source:
    inline:
      source: |
        import json

        def main(event, context):
            data = json.loads(event["data"])

            # Transform S/4HANA BP format to internal format
            transformed = {
                "id": data.get("BusinessPartner"),
                "name": data.get("BusinessPartnerFullName"),
                "email": data.get("EmailAddress", "").lower(),
                "region": map_country_to_region(data.get("Country", ""))
            }

            return {"statusCode": 200, "body": json.dumps(transformed)}

        def map_country_to_region(country):
            regions = {"DE": "EMEA", "US": "NA", "JP": "APJ"}
            return regions.get(country, "UNKNOWN")
      dependencies: |
        requests==2.31.0

Git Source (Production Pattern)

For production, store function source in Git instead of inline:

apiVersion: serverless.kyma-project.io/v1alpha2
kind: Function
metadata:
  name: order-validator
spec:
  runtime: nodejs20
  source:
    gitRepository:
      url: "https://github.com/my-org/my-functions.git"
      baseDir: "/functions/order-validator"
      reference: "main"
      auth:
        type: key
        secretName: git-creds

3. Binding BTP Services to Functions

Using Environment Variables from Secrets

apiVersion: serverless.kyma-project.io/v1alpha2
kind: Function
metadata:
  name: bp-event-handler
spec:
  runtime: nodejs20
  source:
    inline:
      source: |
        const axios = require('axios');

        module.exports = {
          main: async function (event, context) {
            // Credentials from BTP service binding (mounted as env vars)
            const xsuaaUrl = process.env.XSUAA_URL;
            const clientId = process.env.XSUAA_CLIENTID;
            const clientSecret = process.env.XSUAA_CLIENTSECRET;

            // Get OAuth token
            const tokenResponse = await axios.post(
              `${xsuaaUrl}/oauth/token`,
              'grant_type=client_credentials',
              { auth: { username: clientId, password: clientSecret } }
            );

            // Call S/4HANA via destination
            const bpId = JSON.parse(event.data).BusinessPartner;
            // ... fetch full BP data

            return { statusCode: 200, body: { processed: bpId } };
          }
        };
      dependencies: |
        { "dependencies": { "axios": "^1.6.0" } }
  env:
    - name: XSUAA_URL
      valueFrom:
        secretKeyRef:
          name: my-xsuaa-secret
          key: url
    - name: XSUAA_CLIENTID
      valueFrom:
        secretKeyRef:
          name: my-xsuaa-secret
          key: clientid
    - name: XSUAA_CLIENTSECRET
      valueFrom:
        secretKeyRef:
          name: my-xsuaa-secret
          key: clientsecret

4. Event-Triggered Functions

Connecting a Function to Kyma Eventing

# Step 1: Deploy the function
apiVersion: serverless.kyma-project.io/v1alpha2
kind: Function
metadata:
  name: bp-event-processor
  namespace: my-app
spec:
  runtime: nodejs20
  source:
    inline:
      source: |
        module.exports = {
          main: async function (event, context) {
            console.log("Received CloudEvent:", JSON.stringify(event.extensions));
            console.log("Event data:", event.data);

            const bpId = JSON.parse(event.data).BusinessPartner;
            console.log(`Processing BP: ${bpId}`);

            // Your processing logic here

            return { statusCode: 200, body: { processed: true } };
          }
        };
---
# Step 2: Create subscription pointing to the function's service
apiVersion: eventing.kyma-project.io/v1alpha2
kind: Subscription
metadata:
  name: bp-changed-sub
  namespace: my-app
spec:
  source: ""
  types:
    - sap.s4.beh.businesspartner.changed.v1
  sink: http://bp-event-processor.my-app.svc.cluster.local
  config:
    maxInFlightMessages: "10"

HTTP-Triggered Functions (API Gateway)

# Expose function via APIRule
apiVersion: gateway.kyma-project.io/v1beta1
kind: APIRule
metadata:
  name: order-validator-api
  namespace: my-app
spec:
  host: order-validator.kyma.ondemand.com
  service:
    name: order-validator  # auto-created Service for the Function
    port: 80
  gateway: kyma-system/kyma-gateway
  rules:
    - path: /.*
      methods: ["POST"]
      accessStrategies:
        - handler: jwt
          config:
            jwks_urls:
              - "https://mysubaccount.authentication.eu10.hana.ondemand.com/token_keys"

5. Practical Patterns

Pattern: Event Router

Route different event types to different processing logic:

module.exports = {
  main: async function (event, context) {
    const eventType = event.extensions?.type || event.type;

    switch (eventType) {
      case 'sap.s4.beh.businesspartner.created.v1':
        return handleBPCreated(event);
      case 'sap.s4.beh.businesspartner.changed.v1':
        return handleBPChanged(event);
      default:
        console.warn(`Unknown event type: ${eventType}`);
        return { statusCode: 200, body: { skipped: true } };
    }
  }
};

Pattern: Webhook Adapter

Convert external webhook formats to CloudEvents for internal consumption:

const axios = require('axios');

module.exports = {
  main: async function (event, context) {
    // Receive Stripe webhook
    const stripeEvent = JSON.parse(event.data);

    // Transform to CloudEvent and publish to internal topic
    const cloudEvent = {
      specversion: "1.0",
      type: `my.company.payment.${stripeEvent.type}.v1`,
      source: "/stripe/webhook",
      id: stripeEvent.id,
      data: {
        amount: stripeEvent.data.object.amount,
        currency: stripeEvent.data.object.currency,
        customerId: stripeEvent.data.object.customer
      }
    };

    // Forward to NATS/Event Mesh
    await axios.post('http://eventing-publisher.eventing-system.svc.cluster.local/publish', cloudEvent);

    return { statusCode: 200 };
  }
};

Top 5 Pitfalls

  1. Using functions for complex business logic. If your function exceeds ~300 LOC or needs multiple modules, switch to a microservice.
  2. Ignoring cold start latency. Scale-to-zero means the first request after idle will be slow (~2-5s). Set minReplicas: 1 for latency-sensitive endpoints.
  3. Not testing locally. Kyma functions are hard to test locally. Extract core logic into testable modules and import them.
  4. Storing state in function memory. Functions can scale to zero and back. Use external storage (Redis, HANA) for any state.
  5. Inline source in production. Use Git source for version control, code review, and CI/CD integration.

What to Learn Next

  • Lesson 2.1: Kyma Architecture — understanding the serverless module
  • Lesson 2.4: Kyma Eventing — event-driven extension patterns
  • Lesson 1.3: Event Mesh — the messaging backbone for functions