Back to Tutorials
SAP BTPCAP/CAPM

Associations vs Compositions in SAP CAP

Rabi DeyashiSeptember 20, 20268 min read

If you've spent any time writing CDS models for SAP CAP, you've hit this decision early: do I model this relationship as an Association or a Composition? Get it wrong and you'll either lose cascade-delete behavior you needed, or accidentally delete data you didn't mean to touch. This tutorial walks through both, with real CDS code and a retail scenario to ground the theory.

What is an Association

An Association models a relationship between two entities that have independent lifecycles. Neither entity owns the other — they simply reference each other.

The classic example from a retail/procurement domain: a Vendor supplies goods to a Plant, but a Plant continues to exist if a Vendor relationship changes, and a Vendor continues to exist even if it stops supplying a particular Plant.

entity Vendor : cuid {
  name        : String(100);
  taxId       : String(20);
  plants      : Association to many Plant on plants.vendor = $self;
}

entity Plant : cuid {
  code        : String(10);
  location    : String(100);
  vendor      : Association to Vendor;
}

Key characteristics of an Association:

  • Deleting a Vendor does not delete related Plant records — the foreign key relationship is simply left dangling or nulled, depending on your delete strategy.
  • Either side can be queried and read independently.
  • It maps to a standard OData navigation property, but does not support deep-insert by default.

What is a Composition

A Composition models ownership. The child entity has no meaningful existence outside its parent — when the parent goes, the children go with it. This is a cascade-delete relationship.

The textbook retail example: a PurchaseOrder owns its POLineItem records. A line item without a purchase order makes no sense.

entity PurchaseOrder : cuid {
  poNumber    : String(20);
  vendor      : Association to Vendor;
  status      : String(20) default 'DRAFT';
  items       : Composition of many POLineItem on items.parent = $self;
}

entity POLineItem : cuid {
  parent      : Association to PurchaseOrder;
  material    : String(40);
  quantity    : Integer;
  unitPrice   : Decimal(15,2);
}

Key characteristics of a Composition:

  • Deleting the PurchaseOrder cascades — all associated POLineItem records are deleted automatically by the CAP runtime.
  • The child entity is considered "contained" within the parent — in OData terms, it's part of the parent's entity set structure, not a separate top-level resource you'd typically create independently.
  • Compositions are what make deep-insert possible.

When OData Deep-Insert Works

This is the part that trips people up in practice: deep-insert only works through Compositions, not Associations.

If items on PurchaseOrder is a Composition, a client can POST a single payload like this and CAP will create the parent and all children in one atomic request:

POST /odata/v4/procurement/PurchaseOrder
{
  "poNumber": "PO-2026-0091",
  "vendor_ID": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "DRAFT",
  "items": [
    { "material": "MAT-001", "quantity": 100, "unitPrice": 12.50 },
    { "material": "MAT-002", "quantity": 50, "unitPrice": 8.75 }
  ]
}

CAP resolves items as a Composition, generates the child POLineItem records, wires up the foreign keys, and persists everything transactionally.

If you tried the same deep-insert shape against an Association — say, trying to create Plant records nested inside a Vendor POST — it would fail, or CAP would simply ignore the nested payload, because Associations don't imply containment. You'd need separate requests: create the Vendor, then create each Plant referencing it.

Real-World Use Case: Retail Purchase Order Flow

In a real retail CAP application, this distinction shows up constantly. Consider a bulk purchase order creation flow:

service ProcurementService {
  entity PurchaseOrder as projection on my.PurchaseOrder;
  entity POLineItem    as projection on my.POLineItem;
  entity Vendor        as projection on my.Vendor;
  entity Plant         as projection on my.Plant;

  action bulkCreatePO(orders : many PurchaseOrder) returns array of PurchaseOrder;
}

The bulkCreatePO action, implemented with a cds.tx transaction, inserts each PurchaseOrder along with its items in one deep-insert per order — relying entirely on the Composition relationship to cascade the line items correctly and to guarantee that if any line item insert fails, the whole PO rolls back.

Meanwhile, the vendor reference on each PurchaseOrder stays a plain Association — vendors are managed independently through their own master-data flow, and a PO simply points to an existing vendor rather than owning or cascading into it.

Summary

AssociationComposition
LifecycleIndependentOwned by parent
Cascade deleteNoYes
Deep-insertNot supportedSupported
Use forReferences (Vendor → Plant)Ownership (PurchaseOrder → POLineItem)

Model your relationships based on ownership semantics, not just "these two things are related." If deleting the parent should delete the child, and you want deep-insert to work, reach for a Composition. Otherwise, an Association is the right — and lighter-weight — tool.