RAP: Composition vs Association — What Actually Changes
One of the most important modelling decisions when building a RAP service is whether a relationship is a Composition or an Association. It's not a syntax detail: locking, cascade deletion, child creation and concurrency all depend on this choice. Getting it wrong means fighting the framework instead of leveraging it.
To see what actually changes, in this article we follow a classic header/items model along the whole path: from the declaration in the CDS views, to the behavior it drives in the Behavior Definition, all the way to the service exposure.
The two concepts
Composition is a parent → child relationship with a shared lifecycle: the child does not exist without the parent. Delete the header? The framework deletes the items. The child's lock and authorization are derived from the parent. It's the classic SAP document pattern (header/items).
Association is a simple reference to an entity that lives on its own. It doesn't enter the transactional lifecycle of the business object: it's navigation, typically read-only. Classic example: the "owner" field pointing to a Business User.
Rule of thumb: if the child entity only makes sense in the context of the parent → Composition. If it's a reference to something that exists independently → Association.
In our example:
Header→Item: Composition (items don't exist without the header)Header→I_BusinessUser: Association (the user exists independently)
Step 1 — The interface model: where the choice is declared
Composition and association are declared in the interface model (I_), the views we will later define the behavior on. Three syntax rules:
- The
compositionis declared in the parent. - The
association to parentis declared in the child — the two declarations always come as a pair. - Only the root uses
define root view entity; the child usesdefine view entity.
ZRAP_SG_I_Header — header interface view (ROOT)
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Header - Interface View'
define root view entity ZRAP_SG_I_Header
as select from zrap_sg_header
// COMPOSITION: the parent declares its own children.
// Cascade delete, locking and create-by-association derive from here.
composition [0..*] of ZRAP_SG_I_Item as _Items
// ASSOCIATION: reference to an external entity, outside the
// lifecycle of the business object.
association [0..1] to I_BusinessUser as _Owner
on $projection.Owner = _Owner.UserID
{
key header_uuid as HeaderUuid,
header_id as HeaderId,
title as Title,
status as Status,
priority as Priority,
owner as Owner,
start_date as StartDate,
end_date as EndDate,
// Administrative fields: the @Semantics annotations let
// the managed runtime fill them automatically
@Semantics.user.createdBy: true
created_by as CreatedBy,
@Semantics.systemDateTime.createdAt: true
created_at as CreatedAt,
@Semantics.user.lastChangedBy: true
last_changed_by as LastChangedBy,
@Semantics.systemDateTime.lastChangedAt: true
last_changed_at as LastChangedAt,
// Expose the navigations
_Items,
_Owner
}
ZRAP_SG_I_Item — items interface view (CHILD)
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Item - Interface View'
define view entity ZRAP_SG_I_Item
as select from zrap_sg_item
// ASSOCIATION TO PARENT: mandatory in the child,
// paired with the composition declared in the parent.
association to parent ZRAP_SG_I_Header as _Header
on $projection.HeaderUuid = _Header.HeaderUuid
// Association to an external entity (assigned user)
association [0..1] to I_BusinessUser as _Assignee
on $projection.Assignee = _Assignee.UserID
{
key item_uuid as ItemUuid,
header_uuid as HeaderUuid,
item_pos as ItemPos,
item_text as ItemText,
item_status as ItemStatus,
assignee as Assignee,
remark as Remark,
@Semantics.user.createdBy: true
created_by as CreatedBy,
@Semantics.systemDateTime.createdAt: true
created_at as CreatedAt,
@Semantics.user.lastChangedBy: true
last_changed_by as LastChangedBy,
@Semantics.systemDateTime.lastChangedAt: true
last_changed_at as LastChangedAt,
_Header,
_Assignee
}
Step 2 — The projection views: the choice propagates
The consumption layer (C_) projects the interface model for the specific service. Here the distinction shows up again in the redirection syntax: the composition must be explicitly redirected to the child's projection, while the association to external entities is re-exposed as is.
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Header - Projection View'
@Metadata.allowExtensions: true
define root view entity ZRAP_SG_C_Header
provider contract transactional_query
as projection on ZRAP_SG_I_Header
{
key HeaderUuid,
HeaderId,
Title,
Status,
Priority,
Owner,
StartDate,
EndDate,
// The composition must be redirected to the child's projection
_Items : redirected to composition child ZRAP_SG_C_Item,
// The external association is re-exposed without redirection
_Owner
}
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Item - Projection View'
@Metadata.allowExtensions: true
define view entity ZRAP_SG_C_Item
as projection on ZRAP_SG_I_Item
{
key ItemUuid,
HeaderUuid,
ItemPos,
ItemText,
ItemStatus,
Assignee,
Remark,
_Header : redirected to parent ZRAP_SG_C_Header,
_Assignee
}
Step 3 — Behavior Definition: where the choice becomes behavior
The BDEF is defined on the interface model. In a pure managed scenario like this one, the framework implements persistence by itself: you just declare the operations. And this is where the difference between composition and association shows best: the child appears in the BDEF with lock, authorization and etag derived from the parent, while _Owner and _Assignee don't appear at all — external associations have no transactional behavior.
managed;
strict ( 2 );
// ROOT ENTITY: HEADER
define behavior for ZRAP_SG_I_Header alias Header
persistent table zrap_sg_header
lock master
authorization master ( instance )
etag master LastChangedAt
{
create;
update;
delete;
// The composition enables creating children through the parent
association _Items { create; }
// UUID key generated by the framework
field ( numbering : managed, readonly ) HeaderUuid;
field ( readonly ) CreatedBy, CreatedAt, LastChangedBy, LastChangedAt;
// HeaderId can only be set at creation time
field ( readonly : update ) HeaderId;
// CDS names (CamelCase) differ from the DB columns:
// without the mapping the managed runtime cannot persist
mapping for zrap_sg_header
{
HeaderUuid = header_uuid;
HeaderId = header_id;
Title = title;
Status = status;
Priority = priority;
Owner = owner;
StartDate = start_date;
EndDate = end_date;
CreatedBy = created_by;
CreatedAt = created_at;
LastChangedBy = last_changed_by;
LastChangedAt = last_changed_at;
}
}
// CHILD ENTITY: ITEM
define behavior for ZRAP_SG_I_Item alias Item
persistent table zrap_sg_item
lock dependent by _Header
authorization dependent by _Header
etag dependent by _Header
{
update;
delete;
// Navigation to the parent (mandatory for the composition)
association _Header;
field ( numbering : managed, readonly ) ItemUuid;
field ( readonly ) HeaderUuid, CreatedBy, CreatedAt,
LastChangedBy, LastChangedAt;
mapping for zrap_sg_item
{
ItemUuid = item_uuid;
HeaderUuid = header_uuid;
ItemPos = item_pos;
ItemText = item_text;
ItemStatus = item_status;
Assignee = assignee;
Remark = remark;
CreatedBy = created_by;
CreatedAt = created_at;
LastChangedBy = last_changed_by;
LastChangedAt = last_changed_at;
}
}
Three details that follow directly from the composition:
lock dependent by _Header: locking an item locks the whole header. There is only one business object.- No
create;on the child: items can only be created through the parent, viaassociation _Items { create; }. An item cannot be born an orphan. etag dependent by _Header: optimistic concurrency is governed by the parent's ETag.
On the consumption model you then need the projection behavior definition, which simply reuses what is declared below:
projection;
strict ( 2 );
define behavior for ZRAP_SG_C_Header alias Header
{
use create;
use update;
use delete;
use association _Items { create; }
}
define behavior for ZRAP_SG_C_Item alias Item
{
use update;
use delete;
use association _Header;
}
Step 4 — Service Definition and Binding
The Service Definition exposes the projection views. We also expose I_BusinessUser so the _Owner and _Assignee navigations can be expanded via OData:
@EndUserText.label: 'Service Definition Document'
define service ZRAP_SG_SD_Document {
expose ZRAP_SG_C_Header as Header;
expose ZRAP_SG_C_Item as Item;
expose I_BusinessUser as BusinessUser;
}
The Service Binding is created from ADT:
Binding Type : OData V4 - UI
Service Def : ZRAP_SG_SD_Document
Service Name : ZRAP_SG_SB_DOCUMENT
Version : 0001
Endpoint:
/sap/opu/odata4/sap/zrap_sg_sb_document/srvd/sap/zrap_sg_sd_document/0001/
Step 5 — OData V4 calls in practice
CREATE the header — a plain POST on the root:
POST .../Header
Content-Type: application/json
{
"Title" : "Sample document",
"Status" : "OP",
"Priority" : "1",
"Owner" : "USER01",
"StartDate" : "2026-06-01",
"EndDate" : "2026-12-31"
}
CREATE an item — only through the parent. It's the direct consequence of association _Items { create; }:
POST .../Header(a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d)/_Items
Content-Type: application/json
{
"ItemPos" : 10,
"ItemText" : "First sample line",
"ItemStatus" : "OP",
"Assignee" : "USER02"
}
The composition also enables deep insert: header and items in a single atomic POST.
POST .../Header
Content-Type: application/json
{
"Title" : "Document with items",
"Status" : "OP",
"_Items" : [
{ "ItemPos": 10, "ItemText": "Line 1", "ItemStatus": "OP" },
{ "ItemPos": 20, "ItemText": "Line 2", "ItemStatus": "OP" }
]
}
Now try a POST on /_Owner: it's not possible. An association is navigation, not ownership — for $expand, however, the two relationships behave exactly the same:
GET .../Header(a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d)
?$expand=_Items,_Owner
UPDATE — the If-Match works because in the BDEF we declared etag master LastChangedAt; without that line the service would not handle optimistic concurrency:
PATCH .../Header(a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d)
Content-Type: application/json
If-Match: W/"20260617120000.0000000"
{
"Priority" : "2",
"EndDate" : "2026-11-30"
}
DELETE — where the composition shows the most:
# Delete a single item
DELETE .../Item(b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e)
If-Match: W/"20260617130000.0000000"
# DELETE on the HEADER: automatic cascade.
# The framework deletes all child items first,
# then the header. Zero custom code.
DELETE .../Header(a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d)
If-Match: W/"20260617120000.0000000"
# Response: 204 No Content
If _Items had been an association, none of this would exist: no cascade, no create through the parent, no derived lock. You would have two independent entities to orchestrate by hand.
In summary
| Composition | Association | |
|---|---|---|
| Lifecycle | Shared with the parent | Independent |
| Where it's declared | In the parent (composition of) + to parent in the child | In the referencing entity |
| In the BDEF | The child has dependent by behavior | Doesn't appear |
| Child create | Through the parent (association { create; }) or deep insert | N/A — navigation only |
| Parent delete | Automatic cascade on the children | No effect on the referenced entity |
| Lock / ETag | Derived from the parent | N/A |
The distinction between Composition and Association defines who owns the data's lifecycle. Composition ties header and items into a single transactional business object; Association remains a lightweight reference to data that lives on its own. Choosing correctly right from the CDS model means letting RAP work for you.