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

SAP BTP Multi-Tenancy — SaaS Architecture for Java Developers

Lesson 1.4 — BTP Multi-Tenancy

Table of Contents

  • 1. BTP Multi-Tenancy Model
  • 2. Tenant Isolation Patterns
  • 3. XSUAA in Multi-Tenant Context
  • 4. CAP Multi-Tenancy Support (@sap/cds-mtxs)
  • 5. Operational Concerns
  • Top 5 Pitfalls
  • What to Learn Next

Summary: Multi-tenancy on SAP BTP enables building SaaS applications where a single deployment serves multiple customers (tenants) with data isolation, independent configuration, and per-tenant extensibility. This lesson covers the BTP multi-tenancy lifecycle, tenant isolation patterns, XSUAA identity zones, CAP’s MTX framework, and operational concerns — compared with Spring Boot / Hibernate multi-tenancy patterns.

1. BTP Multi-Tenancy Model

Core Concept

In BTP’s multi-tenancy model, your application is deployed once in a provider subaccount, and customer organizations subscribe from their own subaccounts:

┌─────────────────────────────────┐
│     Provider Subaccount          │
│     (your SaaS app deployed)     │
│                                  │
│  ┌────────────────────────────┐  │
│  │  App Instance (shared)     │  │
│  │  - Single CF app / K8s pod │  │
│  │  - Shared code, shared URL │  │
│  └────────────────────────────┘  │
└──────────┬───────────────────────┘
           │ subscriptions
     ┌─────┼──────────────┐
     │     │              │
     ▼     ▼              ▼
┌────────┐ ┌────────┐ ┌────────┐
│Tenant A│ │Tenant B│ │Tenant C│
│(sub-   │ │(sub-   │ │(sub-   │
│account)│ │account)│ │account)│
│        │ │        │ │        │
│ Own HDI│ │ Own HDI│ │ Own HDI│
│ Own IDP│ │ Own IDP│ │ Own IDP│
└────────┘ └────────┘ └────────┘

SaaS Provisioning Service (saas-registry)

The saas-registry service manages the tenant subscription lifecycle:

{
  "xsappname": "my-saas-app",
  "appUrls": {
    "onSubscription": "https://my-saas-app-srv.cfapps.eu10.hana.ondemand.com/mt/v1.0/subscriptions/tenants/{tenantId}",
    "getDependencies": "https://my-saas-app-srv.cfapps.eu10.hana.ondemand.com/mt/v1.0/subscriptions/dependencies"
  }
}

Subscription Lifecycle

Tenant Admin                SaaS Registry           Your App (callback)        HANA Cloud
     │                           │                        │                       │
     │ Subscribe to app          │                        │                       │
     │──────────────────────────→│                        │                       │
     │                           │ PUT /subscriptions/    │                       │
     │                           │     tenants/{tenantId} │                       │
     │                           │───────────────────────→│                       │
     │                           │                        │                       │
     │                           │                        │ Create HDI container  │
     │                           │                        │──────────────────────→│
     │                           │                        │      OK              │
     │                           │                        │←─────────────────────│
     │                           │                        │                       │
     │                           │                        │ Deploy schema         │
     │                           │                        │──────────────────────→│
     │                           │                        │      OK              │
     │                           │                        │←─────────────────────│
     │                           │                        │                       │
     │                           │  200 OK + app URL      │                       │
     │                           │←──────────────────────│                       │
     │  App available at         │                        │                       │
     │  tenant-a.my-app.com      │                        │                       │
     │←─────────────────────────│                        │                       │

2. Tenant Isolation Patterns

Schema-Per-Tenant (HDI Containers)

This is the default and recommended pattern on BTP:

HANA Cloud Instance
├── HDI Container (Tenant A) → Schema: TENANT_A_HDI
│   ├── Table: BOOKS
│   └── Table: AUTHORS
├── HDI Container (Tenant B) → Schema: TENANT_B_HDI
│   ├── Table: BOOKS
│   └── Table: AUTHORS
└── HDI Container (Tenant C) → Schema: TENANT_C_HDI
    ├── Table: BOOKS
    └── Table: AUTHORS

Pros:

  • Complete data isolation at the database level
  • Tenants cannot accidentally access each other’s data
  • Independent schema evolution per tenant (for extensibility)
  • GDPR compliance: delete tenant = drop HDI container

Cons:

  • Resource overhead: each container has metadata tables
  • Schema upgrades must propagate to all containers
  • Cross-tenant queries impossible (by design)
  • HANA Cloud connection limits may become a bottleneck

Shared-Schema with Discriminator Column

All tenants share the same tables, distinguished by a tenant_id column:

CREATE TABLE books (
    id UUID PRIMARY KEY,
    tenant_id VARCHAR(36) NOT NULL,  -- discriminator
    title VARCHAR(200),
    price DECIMAL(10,2)
);

-- Every query MUST include tenant_id filter
SELECT * FROM books WHERE tenant_id = 'tenant-a-guid' AND price > 10;

Pros:

  • Lower resource overhead
  • Simpler deployment (single schema)
  • Cross-tenant analytics possible (with proper authorization)

Cons:

  • Risk of data leakage if tenant filter is missing
  • No database-level isolation
  • Complex GDPR data deletion
  • CAP does not support this pattern natively

Comparison with Spring Boot / Hibernate

Aspect Spring Boot + Hibernate CAP on BTP
Schema-per-tenant MultiTenantConnectionProvider + CurrentTenantIdentifierResolver Built-in via @sap/cds-mtxs
Shared schema @TenantId column + Hibernate filters Not natively supported
Tenant resolution Custom filter / interceptor Automatic from JWT zid claim
Schema migration Flyway per-tenant loop HDI deployer per-container
Tenant provisioning Custom code saas-registry callbacks

3. XSUAA in Multi-Tenant Context

Tenant Resolution via Subdomain

The approuter resolves the tenant from the URL subdomain:

https://tenant-a.my-saas-app.cfapps.eu10.hana.ondemand.com
         ^^^^^^^^
         subdomain → maps to tenant identity zone

The approuter:

  1. Extracts subdomain from the request URL
  2. Resolves it to an XSUAA identity zone
  3. Redirects the user to the tenant-specific XSUAA for authentication
  4. Receives a JWT with the tenant’s zid (zone ID) claim

JWT Tenant Claims

{
  "zid": "tenant-a-zone-guid",
  "ext_attr": {
    "subaccountid": "tenant-a-subaccount-guid",
    "zdn": "tenant-a"
  },
  "iss": "https://tenant-a.authentication.eu10.hana.ondemand.com/oauth/token",
  "scope": ["my-saas-app!t12345.Viewer"],
  "client_id": "sb-my-saas-app!t12345"
}

The zid claim is used by CAP to route database queries to the correct HDI container.

xs-security.json for Multi-Tenancy

{
  "xsappname": "my-saas-app",
  "tenant-mode": "shared",
  "scopes": [
    { "name": "$XSAPPNAME.Viewer", "description": "View data" },
    { "name": "$XSAPPNAME.Admin", "description": "Administer" },
    { "name": "$XSAPPNAME.mtcallback", "description": "MT callback", "grant-as-authority-to-same-serviceplan": ["application"] }
  ],
  "role-templates": [
    { "name": "Viewer", "scope-references": ["$XSAPPNAME.Viewer"] },
    { "name": "Admin", "scope-references": ["$XSAPPNAME.Admin", "$XSAPPNAME.Viewer"] }
  ]
}

Note "tenant-mode": "shared" — this enables multi-tenant operation. The mtcallback scope allows the saas-registry to call your onSubscription endpoint.

4. CAP Multi-Tenancy Support (@sap/cds-mtxs)

Architecture

CAP provides the @sap/cds-mtxs module (MTX Sidecar) that handles:

  • Tenant provisioning (HDI container creation)
  • Schema deployment per tenant
  • Schema upgrade propagation
  • Tenant extensibility (custom CDS extensions per tenant)
┌─────────────────────────────────────────────┐
│              CAP Application                 │
│                                             │
│  ┌─────────────────┐  ┌─────────────────┐  │
│  │  Java Backend    │  │  MTX Sidecar    │  │
│  │  (CAP Java)      │  │  (Node.js)      │  │
│  │                  │  │                  │  │
│  │  - CDS services  │  │  - /mt/v1.0/... │  │
│  │  - Event handlers│  │  - HDI deployer  │  │
│  │  - OData/REST    │  │  - Extension mgr │  │
│  │                  │  │  - Schema upgrade│  │
│  └────────┬─────────┘  └────────┬─────────┘  │
│           │                      │            │
│           └──────────┬───────────┘            │
│                      │                        │
│               Service Bindings                │
│  (XSUAA, saas-registry, service-manager)      │
└──────────────────────┬────────────────────────┘
                       │
              ┌────────┴────────┐
              │  HANA Cloud      │
              │  ┌──────┐       │
              │  │HDI-A │       │
              │  │HDI-B │       │
              │  │HDI-C │       │
              │  └──────┘       │
              └─────────────────┘

Sidecar Configuration

// package.json (MTX sidecar)
{
  "cds": {
    "requires": {
      "multitenancy": true,
      "extensibility": true,
      "toggles": true
    },
    "mtx": {
      "element-prefix": ["Z_", "MY_"],
      "namespace-blocklist": ["com.sap.", "sap."]
    }
  }
}

Schema Upgrade Propagation

When you deploy a new version of your CDS model, the MTX sidecar must upgrade all tenant schemas:

# Trigger upgrade for all tenants
cds-mtx upgrade --all

# Or via REST API
POST /mtx/v1/model/upgrade
{
  "tenants": ["*"]  // all tenants
}

The sidecar iterates through all subscribed tenants, deploys the new schema to each HDI container, and handles migration. This is atomic per tenant — if one tenant’s upgrade fails, others are not affected.

5. Operational Concerns

Tenant-Aware Logging

import org.slf4j.MDC;

@Before(event = "*")
public void addTenantToMDC(EventContext ctx) {
    String tenantId = ctx.getUserInfo().getTenant();
    MDC.put("tenant_id", tenantId);
}

Logback pattern:

<pattern>%d{ISO8601} [%thread] %-5level [tenant=%X{tenant_id}] %logger{36} - %msg%n</pattern>

Noisy-Neighbor Detection

Monitor per-tenant resource consumption:

-- Per-tenant HDI container memory usage
SELECT SCHEMA_NAME,
       ROUND(SUM(MEMORY_SIZE_IN_TOTAL) / 1024 / 1024, 2) AS total_mb,
       SUM(RECORD_COUNT) AS total_records
FROM M_CS_TABLES
GROUP BY SCHEMA_NAME
ORDER BY total_mb DESC;

GDPR: Tenant Data Deletion

With schema-per-tenant, GDPR “right to erasure” is straightforward:

# Unsubscribe tenant → triggers onSubscription DELETE callback
# MTX sidecar drops the HDI container
DELETE /mt/v1.0/subscriptions/tenants/{tenantId}

This physically removes all tenant data, making compliance verification simple.

Connection Pool Considerations

Each tenant’s HDI container requires database connections. With 100 tenants and a pool size of 5 per tenant, you need 500 connections:

# application.yaml
spring:
  datasource:
    hikari:
      maximum-pool-size: 5        # per tenant
      minimum-idle: 1             # keep connections warm
      idle-timeout: 300000        # 5 minutes
      connection-timeout: 30000   # 30 seconds

Pitfall: HANA Cloud has connection limits per instance size. Monitor M_CONNECTIONS and size your HANA instance accordingly.

Top 5 Pitfalls

  1. Forgetting tenant-mode: shared in xs-security.json. Without this, XSUAA creates a dedicated (single-tenant) service instance.
  2. Not testing schema upgrades with multiple tenants. A CDS model change that works for new tenants may fail on existing tenants with data.
  3. Ignoring connection pool sizing. More tenants = more HDI containers = more connections needed. Plan HANA instance sizing early.
  4. Missing the MTX sidecar in deployment. The Java backend alone cannot handle tenant provisioning — the Node.js sidecar is required.
  5. Not implementing tenant-aware logging from day one. Without tenant context in logs, debugging production issues across tenants is nearly impossible.

What to Learn Next

  • Lesson 1.1: BTP Architecture — subaccount hierarchy and entitlements
  • Lesson 1.2: HANA Cloud — HDI container internals
  • Lesson 3.2: CAP Security — authorization in multi-tenant context
  • Lesson 3.4: CAP Advanced Patterns — deployment topologies for MTX
  • SAP BTP Multi-Tenancy documentation

Laravel Developers — You’re Probably Using Only 40% of the Framework

And the other 60% is where the real magic lives.

2015, I was deep in a messy PHP project.

Same database query in five different files. Bugs that kept coming back no matter how many times I fixed them. Every small change felt like I was touching something I shouldn’t. I was tired.

A friend sent me a link with zero explanation. Just — “try this.”

It was Laravel.

I spent the whole weekend reading the docs. Clean routes. Eloquent that actually made sense. Migrations. I was completely hooked.

For the next year I used the same things over and over — routes, controllers, models, Blade templates. I thought that was Laravel. I thought I had it figured out.

I didn’t.

Pareto 3.0: A Lightweight React SSR Framework, Rebuilt on Vite 7

TL;DR: Pareto 3.0 is a ground-up rewrite — Rspack replaced by Vite 7, React 19 required, simplified routing conventions, Immer-powered state management, and a new ParetoErrorBoundary component. Try it: npx create-pareto@latest my-app

If you’ve used Next.js or Remix, you know the React SSR patterns: file-based routing, layouts, loaders, streaming. Pareto gives you the same patterns without the complexity. No server components, no framework lock-in, no config maze.

Pareto 3.0 is the release where the framework caught up with the vision: everything you need to build fast React apps, nothing you don’t.

What changed in 3.0

Vite 7 replaces Rspack

Pareto 2.x used Rspack with separate client/server configs, Babel, and a lazy compiler. All gone.

Pareto 3.0 uses Vite 7:

  • Instant dev server — ready in milliseconds, not seconds
  • Native ESM — no bundling during dev
  • React Fast Refresh — HMR that preserves component state
  • Your Vite plugins work — PostCSS, Tailwind, MDX, etc. No framework wrappers
  • One config — configureVite() in pareto.config.ts
// pareto.config.ts
import type { ParetoConfig } from '@paretojs/core'

const config: ParetoConfig = {
  configureVite(config) {
    config.plugins.push(myVitePlugin())
    return config
  },
}

export default config

React 19

Pareto 3.0 requires React 19. You get use(), Actions, useOptimistic(), and improved Suspense — but no server components. Pareto uses the loader pattern: your components are standard React that works on both server and client.

Simplified routing

Convention files in 3.0:

File Purpose
page.tsx Route component
layout.tsx Wrapping layout
loader.ts Server-side data fetching (new!)
head.tsx Per-route title and meta tags
not-found.tsx 404 page
route.ts JSON API endpoint

New: loader.ts — Define loaders in a separate file. Keeps data fetching separate from components:

// app/dashboard/loader.ts
import type { LoaderContext } from '@paretojs/core'

export function loader(ctx: LoaderContext) {
  return { stats: getDashboardStats() }
}

Changed: Error handling — error.tsx convention is gone. Use ParetoErrorBoundary instead — place it anywhere in the component tree:

import { ParetoErrorBoundary } from '@paretojs/core'

<ParetoErrorBoundary fallback={({ error }) => <p>{error.message}</p>}>
  <RiskyComponent />
</ParetoErrorBoundary>

State management with Immer

defineStore() now uses Immer. Mutate directly, get immutable results:

import { defineStore } from '@paretojs/core/store'

const counterStore = defineStore((set) => ({
  count: 0,
  increment: () => set((draft) => { draft.count++ }),
}))

// Use anywhere — supports direct destructuring
const { count, increment } = counterStore.useStore()

SSR serialization is automatic — no hydration code needed.

Security headers

OWASP-recommended headers out of the box:

import { securityHeaders } from '@paretojs/core/node'

app.use(securityHeaders())

Streaming SSR — the killer feature

The reason Pareto exists. Send the page shell immediately, stream slow data as it resolves:

import { defer, useLoaderData, Await } from '@paretojs/core'
import { Suspense } from 'react'

export function loader() {
  return defer({
    quickData: { total: 42 },           // sent immediately
    slowData: fetchFromDatabase(),       // streamed later
  })
}

export default function Page() {
  const { quickData, slowData } = useLoaderData()

  return (
    <div>
      <h1>{quickData.total} items</h1>
      <Suspense fallback={<Skeleton />}>
        <Await resolve={slowData}>
          {(data) => <DataTable rows={data} />}
        </Await>
      </Suspense>
    </div>
  )
}

Users see content fast. Slow data loads progressively. No full-page spinners.

Migration from 2.x

  1. Install @paretojs/core@3, update to React 19
  2. Remove Rspack config, use configureVite() instead
  3. Replace error.tsx files with ParetoErrorBoundary
  4. Test loaders with Vite’s dev server

Try it

npx create-pareto@latest my-app
cd my-app
npm install
npm run dev

Open http://localhost:3000. Edit app/page.tsx. Done.

Links:

Pareto is MIT licensed and open source. If you find it useful, give it a star on GitHub.

Crunchbase Funding Data: Track Startup Investments with Python

Crunchbase is the definitive source for startup funding data — investment rounds, investors, acquisitions, and company profiles. Tracking this data helps investors, analysts, and founders understand market dynamics and spot emerging trends.

What Funding Data Can You Extract?

  • Funding rounds (seed, Series A-F, IPO)
  • Investor names and portfolios
  • Company valuations and revenue estimates
  • Acquisition data and exit events
  • Founder and executive profiles
  • Industry and geographic trends

Scraping Crunchbase Company Data

import requests
from bs4 import BeautifulSoup
import json
import time
import re

class CrunchbaseScraper:
    BASE_URL = "https://www.crunchbase.com"

    def __init__(self):
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
            'Accept': 'text/html,application/xhtml+xml',
        })

    def get_company_profile(self, company_slug):
        """Get company overview from Crunchbase."""
        url = f"{self.BASE_URL}/organization/{company_slug}"
        resp = self.session.get(url)
        soup = BeautifulSoup(resp.text, 'html.parser')

        # Extract structured data
        scripts = soup.find_all('script', type='application/ld+json')
        ld_data = {}
        for script in scripts:
            try:
                ld_data = json.loads(script.string)
                break
            except (json.JSONDecodeError, TypeError):
                continue

        # Extract key metrics
        profile = {
            'name': ld_data.get('name', ''),
            'description': ld_data.get('description', ''),
            'url': ld_data.get('url', ''),
            'founded': ld_data.get('foundingDate', ''),
        }

        # Parse funding info from page
        funding_section = soup.select_one('[class*="funding"]')
        if funding_section:
            profile['funding_text'] = funding_section.get_text(strip=True)

        return profile

    def search_companies(self, query):
        """Search for companies by name or keyword."""
        url = f"{self.BASE_URL}/textsearch"
        params = {'q': query}
        resp = self.session.get(url, params=params)
        soup = BeautifulSoup(resp.text, 'html.parser')

        results = []
        for item in soup.select('[class*="search-result"]'):
            name = item.select_one('[class*="name"]')
            desc = item.select_one('[class*="description"]')
            link = item.select_one('a')

            if name:
                results.append({
                    'name': name.get_text(strip=True),
                    'description': desc.get_text(strip=True) if desc else '',
                    'url': self.BASE_URL + link['href'] if link else '',
                })

        return results

Building a Funding Tracker

import pandas as pd
from datetime import datetime

class FundingTracker:
    def __init__(self, scraper, db_file='funding_data.csv'):
        self.scraper = scraper
        self.db_file = db_file

    def track_companies(self, company_slugs):
        """Collect and store funding data for multiple companies."""
        records = []
        for slug in company_slugs:
            try:
                profile = self.scraper.get_company_profile(slug)
                profile['slug'] = slug
                profile['collected_at'] = datetime.now().isoformat()
                records.append(profile)
                print(f"  Collected: {profile.get('name', slug)}")
            except Exception as e:
                print(f"  Error with {slug}: {e}")
            time.sleep(3)  # Respect rate limits

        df = pd.DataFrame(records)
        df.to_csv(self.db_file, mode='a', header=False, index=False)
        return df

    def analyze_sector(self, sector_companies):
        """Analyze funding patterns in a sector."""
        df = pd.read_csv(self.db_file)
        sector_df = df[df['slug'].isin(sector_companies)]

        print(f"Sector analysis ({len(sector_df)} companies):")
        print(f"  Companies tracked: {sector_df['name'].nunique()}")

        return sector_df

Investment Pattern Analysis

def analyze_investment_patterns(funding_data):
    """Find patterns in startup funding data."""
    df = pd.DataFrame(funding_data)

    if 'funding_amount' in df.columns:
        # Average round size by stage
        stage_avg = df.groupby('stage')['funding_amount'].agg(['mean', 'median', 'count'])
        print("Funding by stage:")
        print(stage_avg.to_string())

        # Top investors by deal count
        if 'investors' in df.columns:
            all_investors = []
            for inv_list in df['investors'].dropna():
                if isinstance(inv_list, str):
                    all_investors.extend(inv_list.split(', '))

            from collections import Counter
            top_investors = Counter(all_investors).most_common(15)
            print("nMost active investors:")
            for investor, count in top_investors:
                print(f"  {investor}: {count} deals")

    return df

def detect_hot_sectors(funding_data, window_days=90):
    """Identify sectors with increasing funding activity."""
    df = pd.DataFrame(funding_data)
    df['date'] = pd.to_datetime(df.get('date', datetime.now()))

    recent = df[df['date'] > datetime.now() - pd.Timedelta(days=window_days)]

    if 'category' in recent.columns:
        sector_activity = recent.groupby('category').agg(
            deals=('name', 'count'),
            total_funding=('funding_amount', 'sum')
        ).sort_values('deals', ascending=False)

        print(f"Hot sectors (last {window_days} days):")
        print(sector_activity.head(10).to_string())

        return sector_activity

    return pd.DataFrame()

Building Investor Intelligence

def build_investor_profile(funding_data, investor_name):
    """Build a profile of an investor's activity."""
    df = pd.DataFrame(funding_data)

    # Filter deals involving this investor
    investor_deals = df[
        df.get('investors', '').str.contains(investor_name, na=False, case=False)
    ]

    if investor_deals.empty:
        print(f"No deals found for {investor_name}")
        return {}

    profile = {
        'name': investor_name,
        'total_deals': len(investor_deals),
        'companies': investor_deals['name'].tolist(),
    }

    if 'stage' in investor_deals.columns:
        profile['preferred_stages'] = investor_deals['stage'].value_counts().to_dict()

    if 'category' in investor_deals.columns:
        profile['preferred_sectors'] = investor_deals['category'].value_counts().to_dict()

    print(f"Investor: {investor_name}")
    print(f"  Total deals: {profile['total_deals']}")
    print(f"  Recent companies: {', '.join(profile['companies'][:5])}")

    return profile

Scaling with Cloud Solutions

For comprehensive Crunchbase data covering thousands of companies, the Crunchbase Scraper on Apify handles anti-bot measures and data structuring at scale.

Proxy rotation is essential for Crunchbase scraping — ScrapeOps provides intelligent proxy management and request monitoring.

Conclusion

Crunchbase funding data reveals the hidden dynamics of startup ecosystems — where money flows, which sectors heat up, and which investors lead trends. Build your pipeline to track companies, analyze patterns, and detect emerging sectors. Start with targeted company tracking, then scale to sector-wide monitoring for comprehensive investment intelligence.