Skip to content

SAP RAP Guide: One Business Object from Table to Fiori App

RAP is SAP's recommended way to build transactional applications on S/4HANA. Yet most explanations either stop at a single CDS view or drown in a list of concepts. In this article I build a draft-enabled business object from start to finish and explain why each piece is there.

Mustafa Önder Mustafa Önder  ·  3 October 2026  ·  18 min read

Contents

  1. What Is RAP and What Did It Change?
  2. The Layers of a RAP Business Object
  3. Managed, Unmanaged, Draft: Which One?
  4. Step 1: Table and Interface View
  5. Step 2: Behavior Definition
  6. Step 3: Behavior Implementation
  7. Step 4: Projection, Service and Authorization
  8. EML: Using the Business Object from Code
  9. Objects Built on Existing BAPIs: Unmanaged Save
  10. Testing a RAP Object
  11. Common Mistakes
  12. 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.

Why two layers (R_ and C_)? The same business object can be exposed to a Fiori app and to a Web API with different fields and different actions. Rules are written once in the interface layer; the projection only chooses what is visible. Writing business rules into the projection breaks that separation.

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:

Comparison of RAP implementation types by responsibility for the transactional buffer and saving
TypeWho manages the buffer and saving?When?
managedRAP; you only write the business rulesNew development on your own tables — the default choice
managed + with additional saveRAP saves; you do extra work at save timeChange logs, raising events, writing to an extra table
managed + with unmanaged saveRAP manages the buffer; you persistWhen data must be written through an existing API or function
unmanagedBoth buffer and saving are yoursWrapping 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 master prevents two users from overwriting each other's changes; total etag keeps 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: setInitialStatus runs when a record is created; validateDates runs at save time if either date field changed. Because it is listed in Prepare, 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.

The authorization method: 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.
The rule of the save phase: inside 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
← Back to Blog

Contact

Get in touch for your projects.