Contents
- What Is RAP and What Did It Change?
- The Layers of a RAP Business Object
- Managed, Unmanaged, Draft: Which One?
- Step 1: Table and Interface View
- Step 2: Behavior Definition
- Step 3: Behavior Implementation
- Step 4: Projection, Service and Authorization
- EML: Using the Business Object from Code
- Objects Built on Existing BAPIs: Unmanaged Save
- Testing a RAP Object
- Common Mistakes
- Conclusion
In classic ABAP, an application that writes to a table usually grows like this: a Dynpro or a SEGW service, validations buried inside it, hand-written lock handling and scattered decisions about where COMMIT WORK is called. Every new consumer — a Fiori app, a background job, an integration — rewrites the same rules or skips them.
RAP (the ABAP RESTful Application Programming Model) replaces that sprawl with a single model: data lives in CDS, behaviour in the behavior definition, rules in the behavior implementation. Every consumer goes through the same business object. I covered the service side earlier in the OData V4 and CDS article; this one focuses on transactional behaviour — the genuinely hard part.
What Is RAP and What Did It Change?
RAP is SAP's recommended programming model for OData services and transactional applications on S/4HANA. It brings the earlier approaches — Gateway services hand-built in SEGW, and the CDS + BOPF based programming model — under a single roof.
- One business object, many consumers: a Fiori app, a Web API, a background job and another ABAP program all use the same object with the same rules.
- Transaction handling lives in the framework: locking, ETag-based concurrency control, the buffer and the save sequence are run by RAP. You do not write
COMMIT WORK. - Behaviour is declarative: which field is read-only, which actions exist and when each rule runs are defined in the behavior definition, not in code.
- The transactional model of ABAP Cloud: when developing with ABAP Cloud and clean core, RAP is how you build transactional applications; Dynpro and SEGW do not exist in that language version.
The Layers of a RAP Business Object
What confuses people most at first sight is the number of objects created for a single screen. Each one has a clear job:
Data model
The database table holds the data. The interface (R_) CDS view is the reusable data model of the business object; field names are translated into business language and relationships (compositions, associations) are defined here.
Behaviour
The behavior definition (BDEF) is the contract: create/update/delete, actions, validations, locking and draft. An ABAP class called the behavior pool contains the code behind that contract.
Exposure
The projection (C_) view and projection BDEF are the slice of the business object exposed for a particular use. The service definition decides which entities are exposed; the service binding decides the protocol (OData V4 UI, OData V4 Web API…). Access control (DCL) governs read authorization.
Managed, Unmanaged, Draft: Which One?
The first line of the behavior definition sets the object's implementation type. Because it decides how much code you will write yourself, get it right from the start:
| Type | Who manages the buffer and saving? | When? |
|---|---|---|
managed | RAP; you only write the business rules | New development on your own tables — the default choice |
managed + with additional save | RAP saves; you do extra work at save time | Change logs, raising events, writing to an extra table |
managed + with unmanaged save | RAP manages the buffer; you persist | When data must be written through an existing API or function |
unmanaged | Both buffer and saving are yours | Wrapping an existing application that has its own buffer and transaction logic |
Draft is a separate axis and combines with any type. With draft enabled, the user's unsaved changes are kept in a separate draft table: they survive a closed browser, validations can run while the user types, and an "edited by another user" lock kicks in when someone else tries to edit the same record. For transactional apps built with Fiori Elements, draft is almost always the right choice.
The example in this article: a managed, draft-enabled leave request. An employee creates a request, the dates are validated, and a manager approves or rejects it.
Step 1: Table and Interface View
The table includes RAP's administrative fields (created by, last changed by, timestamps). They are needed for ETags and draft, and RAP fills them itself:
@EndUserText.label : 'Leave requests'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #RESTRICTED
define table zleave_req {
key client : abap.clnt not null;
key request_uuid : sysuuid_x16 not null;
employee_id : abap.char(8);
begin_date : abap.dats;
end_date : abap.dats;
status : abap.char(1);
local_created_by : abp_creation_user;
local_created_at : abp_creation_tstmpl;
local_last_changed_by : abp_locinst_lastchange_user;
local_last_changed_at : abp_locinst_lastchange_tstmpl;
last_changed_at : abp_lastchange_tstmpl;
}The interface view is the root entity of the business object. The @Semantics annotations tell RAP which fields to fill automatically:
@AccessControl.authorizationCheck: #CHECK
@EndUserText.label: 'Leave request'
define root view entity ZR_LeaveRequest
as select from zleave_req
{
key request_uuid as RequestUuid,
employee_id as EmployeeId,
begin_date as BeginDate,
end_date as EndDate,
status as Status,
@Semantics.user.createdBy: true
local_created_by as LocalCreatedBy,
@Semantics.systemDateTime.createdAt: true
local_created_at as LocalCreatedAt,
@Semantics.user.localInstanceLastChangedBy: true
local_last_changed_by as LocalLastChangedBy,
@Semantics.systemDateTime.localInstanceLastChangedAt: true
local_last_changed_at as LocalLastChangedAt,
@Semantics.systemDateTime.lastChangedAt: true
last_changed_at as LastChangedAt
}A real object would also have items: a child entity is defined with a composition such as composition [0..*] of ZR_LeaveRequestDay as _Day, and locking, draft and authorization are inherited from the root. In this article I keep the structure to a single entity; I build a header–item structure in the RAP project example using an expense report.
Step 2: Behavior Definition
The behavior definition is the behavioural contract of the object. It is easy to read — clear enough to put on screen even when talking to the business:
managed implementation in class zbp_r_leaverequest unique;
strict ( 2 );
with draft;
define behavior for ZR_LeaveRequest alias LeaveRequest
persistent table zleave_req
draft table zleave_req_d
lock master
total etag LastChangedAt
authorization master ( instance )
etag master LocalLastChangedAt
{
create;
update;
delete;
field ( numbering : managed, readonly ) RequestUuid;
field ( readonly ) Status, LocalCreatedBy, LocalCreatedAt,
LocalLastChangedBy, LocalLastChangedAt, LastChangedAt;
field ( mandatory ) EmployeeId, BeginDate, EndDate;
determination setInitialStatus on modify { create; }
validation validateDates on save { create; field BeginDate, EndDate; }
action ( features : instance ) approve result [1] $self;
action ( features : instance ) reject result [1] $self;
draft action Edit;
draft action Activate optimized;
draft action Discard;
draft action Resume;
draft determine action Prepare { validation validateDates; }
mapping for zleave_req
{
RequestUuid = request_uuid;
EmployeeId = employee_id;
BeginDate = begin_date;
EndDate = end_date;
Status = status;
LocalCreatedBy = local_created_by;
LocalCreatedAt = local_created_at;
LocalLastChangedBy = local_last_changed_by;
LocalLastChangedAt = local_last_changed_at;
LastChangedAt = last_changed_at;
}
}The lines that matter:
strict ( 2 ): switches on the compiler's strictest checks; deprecated syntax and inconsistent definitions become errors. Always on for new objects.draft table: the table that holds draft data. You don't write it by hand; in ADT the quick fix on this line's warning generates it.lock master,etag: the lock is taken on the root entity.etag masterprevents two users from overwriting each other's changes;total etagkeeps the draft and the active record consistent.numbering : managed: RAP generates the UUID key. If a readable request number is needed, add it as a separate field drawn from a number range.- Determination and validation:
setInitialStatusruns when a record is created;validateDatesruns at save time if either date field changed. Because it is listed inPrepare, it also fires during the draft phase — the user sees the error before pressing "Save". features : instance: says the action can be enabled or disabled per record. When it is enabled is decided in code.
Step 3: Behavior Implementation
The behavior pool (zbp_r_leaverequest) starts from the skeleton ADT generates from the BDEF. The code goes into a handler class in the class's local types section:
CLASS lhc_leaverequest DEFINITION INHERITING FROM cl_abap_behavior_handler.
PRIVATE SECTION.
CONSTANTS:
BEGIN OF status,
new TYPE c LENGTH 1 VALUE 'N',
approved TYPE c LENGTH 1 VALUE 'A',
rejected TYPE c LENGTH 1 VALUE 'R',
END OF status.
METHODS get_instance_features FOR INSTANCE FEATURES
IMPORTING keys REQUEST requested_features FOR LeaveRequest RESULT result.
METHODS get_instance_authorizations FOR INSTANCE AUTHORIZATION
IMPORTING keys REQUEST requested_authorizations FOR LeaveRequest RESULT result.
METHODS set_initial_status FOR DETERMINE ON MODIFY
IMPORTING keys FOR LeaveRequest~setInitialStatus.
METHODS validate_dates FOR VALIDATE ON SAVE
IMPORTING keys FOR LeaveRequest~validateDates.
METHODS approve FOR MODIFY
IMPORTING keys FOR ACTION LeaveRequest~approve RESULT result.
METHODS reject FOR MODIFY
IMPORTING keys FOR ACTION LeaveRequest~reject RESULT result.
ENDCLASS.Determination: the initial status
A determination calculates field values in response to an event. Here a new request gets the status "New". IN LOCAL MODE skips authorization and feature control checks for access from inside the object itself — that is the only way to change the read-only Status field:
METHOD set_initial_status.
READ ENTITIES OF zr_leaverequest IN LOCAL MODE
ENTITY LeaveRequest
FIELDS ( Status ) WITH CORRESPONDING #( keys )
RESULT DATA(requests).
DELETE requests WHERE Status IS NOT INITIAL.
CHECK requests IS NOT INITIAL.
MODIFY ENTITIES OF zr_leaverequest IN LOCAL MODE
ENTITY LeaveRequest
UPDATE FIELDS ( Status )
WITH VALUE #( FOR request IN requests
( %tky = request-%tky
Status = status-new ) ).
ENDMETHOD.%tky (the transactional key) carries, alongside the key, whether the record is a draft or active. That is why in draft-enabled objects you pass keys with %tky, not %key.
Validation: checking the dates
A validation changes nothing; it only writes failing records to failed and messages to reported. When messages are bound to fields, Fiori Elements highlights the field in red:
METHOD validate_dates.
READ ENTITIES OF zr_leaverequest IN LOCAL MODE
ENTITY LeaveRequest
FIELDS ( BeginDate EndDate ) WITH CORRESPONDING #( keys )
RESULT DATA(requests).
LOOP AT requests INTO DATA(request).
" Clear the message left over from the previous run
APPEND VALUE #( %tky = request-%tky
%state_area = 'VALIDATE_DATES' ) TO reported-leaverequest.
IF request-EndDate < request-BeginDate.
APPEND VALUE #( %tky = request-%tky ) TO failed-leaverequest.
APPEND VALUE #( %tky = request-%tky
%state_area = 'VALIDATE_DATES'
%msg = new_message(
id = 'ZLEAVE'
number = '001'
severity = if_abap_behv_message=>severity-error )
%element-BeginDate = if_abap_behv=>mk-on
%element-EndDate = if_abap_behv=>mk-on ) TO reported-leaverequest.
ENDIF.
ENDLOOP.
ENDMETHOD.%state_area matters with draft: it makes sure the previous message is cleared when the same validation runs again. Skip it and the old message stays on screen even after the user has fixed the error.
Action and feature control: approval
An action is a business step beyond standard create/update/delete. The approve action changes the status and returns the updated record as its result; Fiori Elements refreshes the screen with it:
METHOD approve.
MODIFY ENTITIES OF zr_leaverequest IN LOCAL MODE
ENTITY LeaveRequest
UPDATE FIELDS ( Status )
WITH VALUE #( FOR key IN keys
( %tky = key-%tky
Status = status-approved ) ).
READ ENTITIES OF zr_leaverequest IN LOCAL MODE
ENTITY LeaveRequest
ALL FIELDS WITH CORRESPONDING #( keys )
RESULT DATA(requests).
result = VALUE #( FOR request IN requests
( %tky = request-%tky
%param = request ) ).
ENDMETHOD.
" reject follows the same pattern and writes status-rejected
METHOD get_instance_features.
READ ENTITIES OF zr_leaverequest IN LOCAL MODE
ENTITY LeaveRequest
FIELDS ( Status ) WITH CORRESPONDING #( keys )
RESULT DATA(requests).
result = VALUE #( FOR request IN requests
LET is_open = COND #( WHEN request-Status = status-new
THEN if_abap_behv=>fc-o-enabled
ELSE if_abap_behv=>fc-o-disabled )
IN ( %tky = request-%tky
%action-approve = is_open
%action-reject = is_open ) ).
ENDMETHOD.Feature control does more than grey out a button: when a disabled action is called some other way — from a Web API, say, or via EML — RAP rejects it too. The rule lives in one place and applies to every consumer.
get_instance_authorizations checks modify and action authority (for example, only a manager may approve). Read authorization is handled in a separate layer, by the access control below. Neither replaces the other.Step 4: Projection, Service and Authorization
The projection view selects the fields exposed to the Fiori app. provider contract transactional_query states that this projection is for a transactional service:
@AccessControl.authorizationCheck: #CHECK
@Metadata.allowExtensions: true
@EndUserText.label: 'Leave request - projection'
define root view entity ZC_LeaveRequest
provider contract transactional_query
as projection on ZR_LeaveRequest
{
key RequestUuid,
EmployeeId,
BeginDate,
EndDate,
Status,
LocalLastChangedAt,
LastChangedAt
}The projection BDEF selects which part of the interface-layer behaviour is exposed by this service. No new rules are written here:
projection;
strict ( 2 );
use draft;
define behavior for ZC_LeaveRequest alias LeaveRequest
use etag
{
use create;
use update;
use delete;
use action approve;
use action reject;
use action Edit;
use action Activate;
use action Discard;
use action Resume;
use action Prepare;
}Service definition and read authorization:
@EndUserText.label: 'Leave request service'
define service ZUI_LEAVEREQUEST_O4 {
expose ZC_LeaveRequest as LeaveRequest;
}@EndUserText.label: 'Leave request read authorization'
@MappingRole: true
define role ZR_LEAVEREQUEST {
grant select on ZR_LeaveRequest
where ( EmployeeId ) = aspect pfcg_auth( ZLEAVE, ZEMPLOYEE, ACTVT = '03' );
}Instead of writing a separate role for the projection and copying the conditions, inherit them with inheriting conditions from entity ZR_LeaveRequest; that stops the two layers' authorizations from drifting apart over time.
The last step is to create and publish an OData V4 - UI service binding from the service definition. The binding's Preview shows a working List Report / Object Page app without opening a separate frontend project. How the screen is shaped with annotations is covered in Fiori Elements or Freestyle?
EML: Using the Business Object from Code
EML (Entity Manipulation Language) is how you access RAP objects from ABAP. If a background job, a file upload or another business object needs to create a leave request, it does not write an INSERT into the table; it uses the object through EML — and every validation, determination and authorization check applies:
MODIFY ENTITIES OF zr_leaverequest
ENTITY LeaveRequest
CREATE FIELDS ( EmployeeId BeginDate EndDate )
WITH VALUE #( ( %cid = 'REQ1'
EmployeeId = '00001234'
BeginDate = '20261110'
EndDate = '20261114' ) )
MAPPED DATA(mapped)
FAILED DATA(failed)
REPORTED DATA(reported).
IF failed IS INITIAL.
COMMIT ENTITIES
RESPONSE OF zr_leaverequest
FAILED DATA(commit_failed)
REPORTED DATA(commit_reported).
ENDIF.Two details are often missed. First: because validateDates runs at save time, a date error comes back not from MODIFY but from the commit_failed result of COMMIT ENTITIES; check both. Second: COMMIT ENTITIES is only called outside the object. A commit inside the behavior implementation breaks the RAP transaction model and fails at runtime.
Objects Built on Existing BAPIs: Unmanaged Save
Not every object starts from scratch with its own table. When the data belongs to an SAP standard object and can only be written through a specific API, with unmanaged save is a good middle path: RAP keeps managing the buffer, locks and draft, and you do the persisting.
managed implementation in class zbp_r_deliverynote unique;
strict ( 2 );
with unmanaged save;CLASS lsc_deliverynote DEFINITION INHERITING FROM cl_abap_behavior_saver.
PROTECTED SECTION.
METHODS save_modified REDEFINITION.
ENDCLASS.
CLASS lsc_deliverynote IMPLEMENTATION.
METHOD save_modified.
LOOP AT update-deliverynote INTO DATA(note).
" Persist here: a released API or a local wrapper
zcl_delivery_note_api=>update( note ).
ENDLOOP.
ENDMETHOD.
ENDCLASS.save_modified you don't write COMMIT WORK, don't open dialogs and don't call a function that commits internally. RAP does the commit. Dropping an old BAPI that commits internally in here can leave half a transaction written and half not. If on ABAP Cloud you need to reach an unreleased BAPI, the route is the Tier 2 wrapper I describe in the ABAP Cloud article.Testing a RAP Object
RAP's least discussed benefit is testability. Determinations, validations and actions are small, single-purpose methods; they can be called through EML and their results checked via failed / reported. A test class should answer these two questions:
- If the end date is before the start date, is the record rejected and is the message bound to the right field?
- Is the approve action disabled on an already approved request?
To test without touching the database, use the CDS test double framework and the EML test doubles provided for RAP. I explain the basics of breaking dependencies and test doubles in the ABAP Unit article; with RAP only the tools change, the approach stays the same.
Common Mistakes
Writing business rules into the projection
A validation added to the projection BDEF applies only to that service; a background job using the same object through EML never sees it. Rules belong in the interface layer.
Turning every check into a determination
A determination changes data; a validation only decides. Determinations that "silently fix" bad data surprise users and hide errors. Anything the user needs to know is a validation and a message.
Missing trigger fields
In a definition like on save { create; field BeginDate, EndDate; }, if you forget a field, the validation won't run when that field changes. When a rule starts depending on a new field, update the trigger list too.
Ignoring failed and reported
An EML call doesn't raise an exception; errors come back in the return tables. Code that doesn't check them treats a failed operation as successful. In background jobs especially, that means lost records nobody notices.
Writing to the table directly
Writing an UPDATE straight into a RAP object's table "to be quick" bypasses the lock, the ETag, the validations and draft. The only correct way to write to a table owned by a RAP object is EML.
Conclusion
The first impression of RAP may be "too many objects for one screen". But each of those objects takes on a responsibility that classic development leaves scattered and often rewritten: the data model, the behavioural contract, business rules, exposure and authorization. In return you get a business object that applies the same rules to every consumer, is testable and fits S/4HANA's upgrade model.
My advice for getting started: begin with a small but real process that has its own table — a request, an approval, a maintenance list. Build it managed with draft, write the rules as validations and actions, and cover it with at least two tests. The second object will take half the time of the first. You can find the scope I offer on the development side on the SAP development services page.
Get support on RAP business object design, moving existing developments to RAP and coaching your team.
Request an Initial Consultation