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 locallyOnly 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 dependencycds-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 lockingforUpdate() / forShareLock()Locking is not an OData concept.n/a (locking applies to local DB entities only).
Streamingstream() / 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; a replicated aspect stamps lastReplicatedAt / lastReplicatedBy.

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.