RAP: Composition e Association — cosa cambia davvero
Una delle scelte di modellazione più importanti quando si costruisce un servizio RAP è decidere se una relazione è una Composition o una Association. Non è un dettaglio sintattico: da questa scelta dipendono lock, cancellazione a cascata, creazione dei figli e concorrenza. Sbagliarla significa combattere contro il framework invece di sfruttarlo.
Per capire cosa cambia davvero, in questo articolo seguiamo un classico modello testata/posizioni lungo tutto il percorso: dalla dichiarazione nelle view CDS, al comportamento che ne deriva nella Behavior Definition, fino all'esposizione del servizio.
I due concetti
Composition è una relazione padre → figlio con ciclo di vita condiviso: il figlio non esiste senza il padre. Cancelli la testata? Il framework cancella le posizioni. Il lock e l'autorizzazione del figlio derivano dal padre. È il pattern dei documenti SAP (header/items).
Association è un semplice riferimento a un'entità che vive per conto suo. Non entra nel ciclo di vita transazionale del business object: è navigazione, tipicamente read-only. Esempio classico: il campo "owner" che punta a un Business User.
Regola pratica: se l'entità figlia ha senso solo nel contesto del padre → Composition. Se è un riferimento a qualcosa che esiste indipendentemente → Association.
Nel nostro esempio:
Header→Item: Composition (le posizioni non esistono senza la testata)Header→I_BusinessUser: Association (l'utente esiste indipendentemente)
Step 1 — Il modello interfaccia: qui si dichiara la scelta
Composition e association si dichiarano nel modello interfaccia (I_), le view su cui poi definiremo il behavior. Tre regole sintattiche:
- La
compositionsi dichiara nel padre. - L'
association to parentsi dichiara nel figlio — le due dichiarazioni vanno sempre in coppia. - Solo il root usa
define root view entity; il figlio usadefine view entity.
ZRAP_SG_I_Header — interface view testata (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: il padre dichiara i propri figli.
// Da qui derivano cascade delete, lock e create-by-association.
composition [0..*] of ZRAP_SG_I_Item as _Items
// ASSOCIATION: riferimento a entità esterna, fuori dal
// ciclo di vita del 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,
// Campi amministrativi: le annotazioni @Semantics permettono
// al runtime managed di valorizzarli automaticamente
@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,
// Esposizione delle navigazioni
_Items,
_Owner
}
ZRAP_SG_I_Item — interface view posizioni (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: obbligatoria nel figlio,
// in coppia con la composition dichiarata nel padre.
association to parent ZRAP_SG_I_Header as _Header
on $projection.HeaderUuid = _Header.HeaderUuid
// Association a entità esterna (utente assegnato)
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 — Le projection views: la scelta si propaga
Il layer di consumo (C_) proietta il modello interfaccia per lo specifico servizio. Qui la distinzione riappare nella sintassi di redirezione: la composition va rediretta esplicitamente verso la projection del figlio, l'association a entità esterne si ri-espone così com'è.
@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,
// La composition va rediretta verso la projection del figlio
_Items : redirected to composition child ZRAP_SG_C_Item,
// L'association esterna si ri-espone senza redirezione
_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: dove la scelta diventa comportamento
Il BDEF si definisce sul modello interfaccia. In uno scenario managed puro come questo, il framework implementa da solo la persistenza: basta dichiarare le operazioni. Ed è qui che la differenza tra composition e association si vede meglio: il figlio compare nel BDEF con lock, authorization ed etag derivati dal padre, mentre _Owner e _Assignee non compaiono affatto — le association esterne non hanno comportamento transazionale.
managed;
strict ( 2 );
// ROOT ENTITY: HEADER (testata)
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;
// La composition abilita la creazione dei figli via padre
association _Items { create; }
// Chiave UUID generata dal framework
field ( numbering : managed, readonly ) HeaderUuid;
field ( readonly ) CreatedBy, CreatedAt, LastChangedBy, LastChangedAt;
// HeaderId modificabile solo in creazione
field ( readonly : update ) HeaderId;
// I nomi CDS (CamelCase) differiscono dalle colonne DB:
// senza mapping il runtime managed non può persistere
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 (posizioni)
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;
// Navigazione verso il padre (obbligatoria per la 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;
}
}
Tre dettagli che derivano direttamente dalla composition:
lock dependent by _Header: bloccare una posizione blocca l'intera testata. Il business object è uno solo.- Niente
create;sul figlio: le posizioni si creano solo passando dal padre, viaassociation _Items { create; }. Un item non può nascere orfano. etag dependent by _Header: la concorrenza ottimistica è governata dall'ETag del padre.
Sul modello di consumo serve poi la projection behavior definition, che si limita a riutilizzare quanto dichiarato sotto:
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 e Binding
La Service Definition espone le projection views. Esponiamo anche I_BusinessUser così le navigazioni _Owner e _Assignee sono espandibili 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;
}
Il Service Binding si crea da 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 — Esempio di chiamata OData V4
CREATE della testata — semplice POST sul root:
POST .../Header
Content-Type: application/json
{
"Title" : "Documento di esempio",
"Status" : "OP",
"Priority" : "1",
"Owner" : "USER01",
"StartDate" : "2026-06-01",
"EndDate" : "2026-12-31"
}
CREATE di una posizione — solo attraverso il padre. È la conseguenza diretta di association _Items { create; }:
POST .../Header(a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d)/_Items
Content-Type: application/json
{
"ItemPos" : 10,
"ItemText" : "Prima riga di esempio",
"ItemStatus" : "OP",
"Assignee" : "USER02"
}
La composition abilita anche il deep insert: testata e posizioni in un'unica POST atomica.
POST .../Header
Content-Type: application/json
{
"Title" : "Documento con posizioni",
"Status" : "OP",
"_Items" : [
{ "ItemPos": 10, "ItemText": "Riga 1", "ItemStatus": "OP" },
{ "ItemPos": 20, "ItemText": "Riga 2", "ItemStatus": "OP" }
]
}
Provate invece a fare POST su /_Owner: non è possibile. Un'association è navigazione, non possesso — per l'$expand, però, le due relazioni si comportano allo stesso modo:
GET .../Header(a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d)
?$expand=_Items,_Owner
UPDATE — l'If-Match funziona perché nel BDEF abbiamo dichiarato etag master LastChangedAt; senza quella riga il servizio non gestirebbe la concorrenza ottimistica:
PATCH .../Header(a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d)
Content-Type: application/json
If-Match: W/"20260617120000.0000000"
{
"Priority" : "2",
"EndDate" : "2026-11-30"
}
DELETE — il momento in cui la composition si vede di più:
# Cancella una singola posizione
DELETE .../Item(b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e)
If-Match: W/"20260617130000.0000000"
# DELETE sulla TESTATA: cascade automatico.
# Il framework cancella prima tutte le posizioni figlie,
# poi la testata. Zero codice custom.
DELETE .../Header(a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d)
If-Match: W/"20260617120000.0000000"
# Risposta: 204 No Content
Se _Items fosse stata un'association, nulla di tutto questo esisterebbe: niente cascade, niente create via padre, niente lock derivato. Avreste due entità indipendenti da orchestrare a mano.
In sintesi
| Composition | Association | |
|---|---|---|
| Ciclo di vita | Condiviso col padre | Indipendente |
| Dove si dichiara | Nel padre (composition of) + to parent nel figlio | Nell'entità che referenzia |
| Nel BDEF | Il figlio ha behavior dependent by | Non compare |
| Create del figlio | Via padre (association { create; }) o deep insert | N/A — solo navigazione |
| Delete del padre | Cascade automatico sui figli | Nessun effetto sull'entità riferita |
| Lock / ETag | Derivati dal padre | N/A |
La distinzione tra Composition e Association definisce chi possiede il ciclo di vita del dato. La Composition lega testata e posizioni in un unico business object transazionale; l'Association resta un riferimento leggero verso dati che vivono per conto loro. Scegliere correttamente fin dal modello CDS significa lasciare che RAP lavori per voi.