Skip to content

Choosing a Strategy ​

cds-data-federation gives you two strategies and, on the delegate strategy, two optional caching modes. That is four runtime shapes for the same consumption view:

#ShapeAnnotation
1Delegate — live proxy, every read forwarded to the remote@federation.delegate
2Delegate + response cache — per-query result cached@federation.delegate: { cache: { strategy: 'response' } }
3Delegate + entity cache — full projection snapshot in local SQLite@federation.delegate: { cache: { strategy: 'entity' } }
4Replicate — scheduled sync into a real local table@federation.replicate

Caching is an option on delegation, not a separate strategy: it keeps live-proxy semantics and adds a freshness window. Replication is a genuinely different shape — the data physically lives in your database.

This page is the decision reference: a quick decision tree, a master comparison matrix, and — because the strategies differ most in what they cannot do — an explicit catalogue of limitations, including OData-protocol limits on delegation.

For the syntax of every option see Annotations; for a per-strategy capability list see Features; for positioning against non-plugin approaches see Comparison with CAP.


Quick decision tree ​

Do you need reads to reflect remote writes immediately (strong consistency)?
├─ YES ─ must never be stale, or data must not be stored locally
│        └─► DELEGATE (no cache)
│
├─ MOSTLY ─ live is fine but the remote is slow / rate-limited / costly
│        ├─ same queries repeat verbatim ............► DELEGATE + response cache
│        └─ arbitrary filters/sorts over one entity .► DELEGATE + entity cache
│
└─ NO ─ staleness is acceptable and you need one of:
         • SQL joins to local tables, GROUP BY, $apply, analytics
         • offline / remote-outage resilience
         • cross-service $expand served from local SQL
         └─► REPLICATE

Rule of thumb: start with plain delegate. Add a cache when a concrete latency/load problem appears; move to replicate when you need SQL power, offline resilience, or local joins that delegation cannot express.


Master comparison matrix ​

Decision signalDelegateDelegate + response cacheDelegate + entity cacheReplicate
FreshnessAlways liveLive within TTLLive within TTLAs of last sync run
Where data livesRemote onlyCache backend (memory / Redis / …)Secondary SQLite (per tenant)Your app's DB table
First-access costOne remote call per requestOne remote call per distinct queryOne remote full-entity read per TTLOne bulk sync per schedule
Repeat-read costOne remote call each timeCache hit (identical query)Local SQL (any query)Local SQL (any query)
Arbitrary filters/sorts served locally—Only identical queriesYes (any CQN on the entity)Yes
Joins with local tablesNoNoNo (separate store)Yes
Aggregation / GROUP BY / $applyNo (OData limit)NoNoYes (full SQL)
Offline / remote-outage resilienceNoUntil TTL (stale)Until TTL (stale)Full — table always present
Reduces remote loadNoYes (per query)Yes (one read/TTL)Yes (one sync/schedule)
Write-back (CUD) to remoteOpt-in, synchronousOpt-in (cache not auto-invalidated)Opt-in (cache not auto-invalidated)Local write only; overwritten by next sync
Cross-service $expand into this entity served locallyNo (live batch-fetch)No — expand bypasses the cacheNo — expand falls back to live remoteYes — native SQL join
Invalidation modeln/aTags + TTLTTL (pipeline reload on miss)Schedule / manual run
Per-tenant isolationRemote's concernAuto tenant tagOne SQLite file per tenantCAP MTX per-tenant DB
Sensitive data kept off local diskYesDepends on backendNo (on local disk)No (in local DB)
Good fit for very large datasetsYesYesDepends on entity sizeDepends on schema
Peer dependency—cds-cachingcds-data-pipeline (+ @cap-js/sqlite)cds-data-pipeline

The two caches never serve a cross-service $expand

When a local entity $expands into a delegated entity (see Cross-service expand: local → remote), the plugin batch-fetches the target straight from the remote service. That path does not go through the entity's own READ handler, so neither the response cache nor the entity cache is consulted — every such expand hits the remote. If you need that join to be cheap, use replicate (native local SQL join) instead. See Caching.


Delegate: OData-level capabilities and limits ​

Delegation forwards remote.run(req.query) and relies on CAP translating the query through the CDS projection chain. That means a delegated entity is only as capable as the remote OData protocol — several CQL/SQL features have no OData equivalent and are rejected.

Unless noted, the supported items work on both OData V4 and V2; several advanced options are V4 only.

Supported ​

CapabilityNotes
Basic $filter (eq, ne, gt, ge, lt, le, and, or, not)CAP-native. V4 + V2.
String functions contains, startswith, endswith, tolower, toupperCAP-native. V4 + V2.
$orderby, $select, $top, $skip, $countColumn restriction follows the projection. V4 + V2.
$expand within the same remote (Delegated expand)CAP-native. V4 + V2.
Nested $expand ($expand=a($expand=b))Recursive rename mapping. V4 + V2.
$filter / $orderby / $top / $skip inside $expandV4 only (see V2 limitation below).
Navigation-path $filter through renamed associations (e.g. buyer/name eq 'X')Plugin translates the association rename. V4 only.
Lambda operators any / all on to-manyPlugin workaround. V4 only.
$searchCAP forwards it to the remote; searches string columns. V4.
Static where in the consumption viewPlugin injects the filter into every remote call — a value-add beyond CAP's native projection support.
Server-driven pagingWhen a remote caps a page below the requested $top (e.g. Northwind's 20-row default), the handler loops $top/$skip and preserves @odata.count.
CQL SELECT.one, .columns(), .where(), .orderBy(), .limit(), key shortcut, tagged templatesThrough the application service. Renamed fields resolve correctly.
Error propagationRemote HTTP status, message, and context reach the client.

Not supported on a delegate (OData-protocol limits) ​

FeatureWhyDo this instead
Aggregation — $apply, CQL .groupBy() / .having()CAP rejects .groupBy for remote services ("Feature not supported: SELECT statement with .groupBy"). OData delegation has no aggregation pushdown.Replicate, then aggregate with local SQL — or use cds-data-materialization.
SELECT.distinctOData has no DISTINCT.Replicate + local SQL.
.where() with likeOData has no like keyword.Use contains / startswith / endswith.
Pessimistic locking — forUpdate() / forShareLock()Locking is not an OData concept.n/a (locking applies to local DB entities only).
Streaming — stream() / pipeline() / foreach()These are DatabaseService-only APIs.Replicate, then stream from the local table.
Flatten associations / path expressions in the projection (denormalization, e.g. customer.name as buyerName)OData cannot denormalize ("OData doesn't support denormalization"). Works only when the remote is HCQL (CAP-to-CAP).Use an HCQL provider, or replicate and flatten locally. See Service Query Execution → HCQL.
Cross-service path expressions (local → remote via association in a projection)@cds.persistence.skip on the delegate target prevents SQL compilation of the cross-service path.Replicate the remote side, then it is an ordinary local path.
REST protocol delegationCAP does not translate CQN to REST; remote.run(req.query) fails against a rest source.Use @federation.replicate with a rest source config.
Renamed fields inside nested-expand $orderby ($expand=orders($orderby=renamedField))The consumer URL parser expects local names; the remote expects remote names; CAP does not translate renames inside nested expand options.Order by a non-renamed field, or sort client-side.
Nested $expand options on OData V2 ($filter / $orderby / $top / $skip inside $expand)The V2 adapter silently ignores nested expand options.Use V4, or filter/sort after expansion.

Write-back (CUD) ​

Delegated entities are read-only by default. Opt in per entity:

AnnotationEffect
writable: trueEnables CREATE + UPDATE + DELETE.
create / update / deleteEnable one operation; overrides writable.
(disabled operation)Returns HTTP 405 rather than a DB error.

CUD forwarding is synchronous — the client receives the remote's response (201/200/204). There is deliberately no transactional-outbox write-through: the outbox defers execution and returns no result, which breaks OData's request/response contract. (The outbox belongs in fire-and-forget scenarios like event notification and cache invalidation.) Note that a successful CUD does not auto-invalidate any cache — see below.


Cross-service $expand and navigation ​

CAP resolves $expand and navigation within a single service. When an association crosses the local ↔ remote boundary, the plugin adds resolution. Support is identical whether the remote side is delegate or replicate (the plugin treats any annotated entity as a federation target), but the fetch path differs.

ScenarioSupportedFetch path
Delegated expand (same remote)YesCAP-native, forwarded to the remote
Cross-service expand: local → remoteYes — incl. to-many, composite keys, nested expand, inner $filter/$orderby/$top/$skip, lambdasBatch-fetch from remote + stitch (bypasses the target's cache)
Cross-service expand: cross-providerYesBatch-fetch from each provider + stitch
Cross-service expand: remote → localYes — incl. per-parent $topForward remote, query local DB, stitch
Cross-service navigation: local → remoteYes — $select + $filter on targetRead local FK, delegate to remote
Cross-service navigation: remote → localYesRewrite to FK-filtered local query

Extra hop vs. a plain JOIN

A cross-service expand into a replicate entity still runs the batch-fetch/stitch path (the plugin treats it as a federation target), not a single SQL JOIN — correct, but an extra lookup hop. When a target can be a plain local (non-annotated) entity, prefer that as the join target for the fastest path.


Caching: response vs. entity, and their limits ​

Both caches attach to @federation.delegate only. Full option reference: Annotations → Cache option; mechanics: Caching.

Response cache (strategy: 'response', default)Entity cache (strategy: 'entity')
Backendcds-caching (memory / Redis / HANA / …)Secondary SQLite (db or data-federation-cache)
Cache keyFull query signature ($filter, $select, $orderby, $top, $skip, $expand)The entity — any CQN answered from the snapshot
Warm-upLazy, per distinct queryOne full remote read per TTL (or preload)
InvalidationTTL + tag (federation:<entity>, plus static/dynamic/template tags)TTL only (pipeline reload on miss) — no tag invalidation
MultitenancyAuto tenant-… tag (opt out via tenantScoped: false)One SQLite file per tenant; static: true for shared reference data

Caching limitations (both) ​

  • Cross-service $expand into the entity is never served from either cache — it hits the remote (see the tip above).
  • No auto-invalidation on CUD. After a write to a writable delegate, stale entries remain until TTL. Invalidate manually (cache.deleteByTag('federation:<entity>') for response) or use a short TTL.
  • Missing peer dependency degrades gracefully. No cds-caching → response caching is skipped with a warning; no cds-data-pipeline/SQLite → entity caching is skipped and live delegation continues.

Entity-cache — falls back to the live remote for ​

The entity cache holds only the flat projected columns of the remote entity in one SQLite table, so it forwards to the live remote whenever a query needs more than that table can answer:

  • a static where on the consumption view,
  • lambda (any/all) filters (routed to the direct-remote path),
  • a cross-service (remote → local) $expand on the delegated entity,
  • a navigation/lambda filter that was rewritten to a local-FK IN filter,
  • $search when search: false,
  • any SQLite read/reload error (transparent fallback).

There is deliberately no scheduled entity-cache refresh — the always-warm-local-copy use case converges on @federation.replicate; use preload: true / wait: false to avoid a user paying the reload wait.


Replicate: capabilities and limits ​

Replication copies remote data into a real local table on a schedule; reads then run entirely as local SQL.

Enables: SQL joins to local tables, GROUP BY / $apply / analytics, offline and remote-outage resilience, cross-service $expand as a native join, streaming, and pessimistic locking — everything delegation's OData limits forbid. Delta sync supports timestamp, key, and datetime-field modes; writes are idempotent UPSERT. Opt in to the replicated aspect when consumers need per-row lastReplicatedAt / lastReplicatedBy freshness metadata.

Limits:

LimitationDetail
StalenessData is only as fresh as the last run. High-churn data that must be exact is a delegate case.
No write-backReplicated tables are read-only mirrors. A local POST/PATCH writes locally, but the next sync treats the remote as source of truth and may overwrite it. Bidirectional sync is not supported.
Soft-delete propagationSource-side deletions are not detected/removed today (no tombstone/diff). Not started.
Reconciliation & dry-runNo periodic drift comparison and no preview-without-write mode. Not started.
Entity orderingParent-before-child ordering for referential integrity is not inferred. Not started.
OData delta tokens$deltatoken / $deltalink change tracking is not supported; use timestamp/key delta. Not started.
Storage & schedule opsYou own table growth and must monitor run history — a silently failing job is the most common cause of stale data.

Protocol support ​

Both strategies work over OData; only replication reaches plain REST.

Protocolcds.requires kindDelegateReplicateNotes
OData V4odataYesYesDefault and recommended.
OData V2odata-v2YesYesNeeds @cap-js-community/odata-v2-adapter; nested $expand options unsupported; deprecated by SAP — prefer V4.
HCQLhcqlYesYesCAP-native; enables flattened path expressions delegation otherwise cannot express.
RESTrestNoYesCAP does not translate CQN to REST. Use replicate with a rest source config.

See also ​

Released under the MIT License.