Unmanaged RAP
RAP / CDS / ODataadvanced

Unmanaged RAP Part 3: Create, Update, Delete and Read Handlers

Key takeaway

Implement the unmanaged handler methods correctly: create into the buffer, partial updates with %control, delete including the create-then-delete case, a buffer-aware read method, and create-by-association for items.

Explanation

📌 KEY POINTS
✅ Create: assign keys, fill MAPPED with %cid → key, store in buffer.

✅ Update: apply only fields flagged in %control (partial update).

✅ Delete and read: work on the buffer first.

⚠️ Handlers never write to the database — only the saver does.

▶ WHERE WE ARE
Part 2 gave you the BDEF, the behavior pool skeleton and the buffer class. Now you fill the handler methods. Remember the rule: handlers only change lcl_buffer. The legacy API is called only by the saver (Part 4).

━━━━━━━━━━━━━━━━━━━━
▶ THE SIGNATURE PATTERN — WHAT EACH HANDLER RECEIVES AND RETURNS

  • Input: "entities" (create, update, create-by-association) or "keys" (delete, read, lock, actions). Always a table — process every row, never assume one.
  • Output: MAPPED (new keys for %cid), FAILED (instances that failed, with %fail-cause), REPORTED (messages). For read methods also RESULT.
  • %cid — client's temporary ID for a new instance. %tky — transactional key of an existing instance.
  • %fail-cause — tells RAP and the OData layer why it failed: not_found (404), unauthorized (403), locked, conflict, unspecific.

━━━━━━━━━━━━━━━━━━━━
▶ 1. CREATE

The key was already assigned by earlynumbering_create (Part 1), so create only builds the row, sets defaults and admin fields, and inserts it into the buffer with flag C. It does not call the legacy API. Mandatory-field and business checks belong to validations or check_before_save, not here, because a UI may create an empty draft first.\n\n━━━━━━━━━━━━━━━━━━━━\n▶ 2. UPDATE AND %CONTROL — THE MOST IMPORTANT DETAIL IN UNMANAGED RAP\nOData updates are usually PATCH: the client sends only the fields that changed. RAP passes the full entity structure, with the unsent fields empty. If you copy the whole structure to your row, every unsent field is overwritten with blank.

%control solves this: for each field, entity-%control-<Field> is if_abap_behv=>mk-on only if the client actually sent it. So the update pattern is:

  1. Get the current state with lcl_buffer=>get_head (buffer first, then database).
  2. Not found or already deleted → FAILED with not_found.
  3. For each changeable field: only if %control is on, copy the value.
  4. Set admin fields — especially the ETag field LocalLastChangedAt.
  5. Keep flag C if the row was created in this transaction, otherwise set U. Store in the buffer.

In managed RAP the framework does this; in unmanaged it is your job, and forgetting it is the single most common unmanaged bug.

━━━━━━━━━━━━━━━━━━━━
▶ 3. DELETE

  • If the instance was created in this same transaction (flag C), just remove it and its items from the buffer — the legacy API never needs to know it existed.
  • Otherwise mark it D. The saver will call the legacy delete, which is responsible for the dependent items.
  • Not found → FAILED with not_found.

━━━━━━━━━━━━━━━━━━━━
▶ 4. READ — THE BUFFER-AWARE READ

RAP calls read for EML READ ENTITIES, for ETag checks before an update, and to return results of actions. Always use get_head so uncommitted changes are visible. Return every requested key in RESULT or in FAILED — never silently skip one. Instances marked D are reported as not found.

The CDS views still serve list queries; the read method is only the transactional path.

━━━━━━━━━━━━━━━━━━━━
▶ 5. CREATE BY ASSOCIATION (ITEMS)

cba_Item receives "entities_cba": one row per parent, each with a %target table of new items. Check the parent exists (buffer or database), then insert each item into the items buffer with flag C and parent_uuid set from the parent key. The item keys were assigned in earlynumbering_cba_Item.

Also update the parent's ETag field, because the aggregate changed.

━━━━━━━━━━━━━━━━━━━━
▶ 6. BULK AND PERFORMANCE

get_head reads one row at a time for clarity. In production, prefetch: before the loop, select all requested keys that are not in the buffer with one SELECT (check that the key table is not empty before FOR ALL ENTRIES), and store them in a read cache. One request from a mass action may contain hundreds of keys.

━━━━━━━━━━━━━━━━━━━━
▶ STEP BY STEP

  1. Implement create, update, delete and read in lhc_PurchaseRequest as in the code example.
  2. Implement cba_Item and rba_Item (read items of a request: buffer items plus database items, minus deleted).
  3. Implement update, delete, read and rba_Request in lhc_Item following the same patterns.
  4. Test with EML: create + update only Description in a second MODIFY, then READ and check that CurrencyCode survived. Create and delete in the same transaction and check the buffer is empty.

━━━━━━━━━━━━━━━━━━━━
▶ WHAT YOU HAVE AFTER PART 3

All interaction-phase behavior works against the buffer. Nothing has reached the database yet — Part 4 implements the saver, which hands the buffer to the legacy API safely.

Code example

ABAP Code
" ===== lhc_PurchaseRequest: CREATE =====  METHOD create.    DATA now TYPE timestampl.    GET TIME STAMP FIELD now.    DATA(user) = cl_abap_context_info=>get_user_technical_name( ).     LOOP AT entities INTO DATA(entity).      " Key already assigned in earlynumbering_create -> no MAPPED needed here      INSERT VALUE lcl_buffer=>ty_head(               request_uuid          = entity-RequestUUID               description           = entity-Description               currency_code         = entity-CurrencyCode               status                = 'N'               requester             = user               created_by            = user               created_at            = now               last_changed_by       = user               local_last_changed_at = now               last_changed_at       = now               change                = lcl_buffer=>flag-created )        INTO TABLE lcl_buffer=>heads.    ENDLOOP.  ENDMETHOD. " ===== lhc_PurchaseRequest: UPDATE with %control (PATCH-safe) =====  METHOD update.    DATA now TYPE timestampl.    GET TIME STAMP FIELD now.     LOOP AT entities INTO DATA(entity).      DATA(head) = lcl_buffer=>get_head( entity-RequestUUID ).      IF head IS INITIAL OR head-change = lcl_buffer=>flag-deleted.        APPEND VALUE #( %tky = entity-%tky                        %fail-cause = if_abap_behv=>cause-not_found ) TO failed-purchaserequest.        CONTINUE.      ENDIF.       " Copy ONLY the fields the client actually sent      IF entity-%control-Description = if_abap_behv=>mk-on.        head-description = entity-Description.      ENDIF.      IF entity-%control-CurrencyCode = if_abap_behv=>mk-on.        head-currency_code = entity-CurrencyCode.      ENDIF.       " Admin + ETag fields: nobody else sets them in unmanaged RAP      head-last_changed_by       = cl_abap_context_info=>get_user_technical_name( ).      head-local_last_changed_at = now.      head-last_changed_at       = now.       IF head-change IS INITIAL.            " came from DB -> mark as updated        head-change = lcl_buffer=>flag-updated.      ENDIF.                                " created in this LUW -> stays 'C'       MODIFY TABLE lcl_buffer=>heads FROM head.      IF sy-subrc <> 0.        INSERT head INTO TABLE lcl_buffer=>heads.      ENDIF.    ENDLOOP.  ENDMETHOD. " ===== lhc_PurchaseRequest: DELETE =====  METHOD delete.    LOOP AT keys INTO DATA(key).      DATA(head) = lcl_buffer=>get_head( key-RequestUUID ).      IF head IS INITIAL OR head-change = lcl_buffer=>flag-deleted.        APPEND VALUE #( %tky = key-%tky                        %fail-cause = if_abap_behv=>cause-not_found ) TO failed-purchaserequest.        CONTINUE.      ENDIF.       IF head-change = lcl_buffer=>flag-created.        " Created and deleted in the same transaction: forget it completely        DELETE lcl_buffer=>heads WHERE request_uuid = key-RequestUUID.        DELETE lcl_buffer=>items WHERE parent_uuid  = key-RequestUUID.      ELSE.        head-change = lcl_buffer=>flag-deleted.        MODIFY TABLE lcl_buffer=>heads FROM head.        IF sy-subrc <> 0.          INSERT head INTO TABLE lcl_buffer=>heads.        ENDIF.      ENDIF.    ENDLOOP.  ENDMETHOD. " ===== lhc_PurchaseRequest: READ (buffer first) =====  METHOD read.    LOOP AT keys INTO DATA(key).      DATA(head) = lcl_buffer=>get_head( key-RequestUUID ).      IF head IS INITIAL OR head-change = lcl_buffer=>flag-deleted.        APPEND VALUE #( %tky = key-%tky                        %fail-cause = if_abap_behv=>cause-not_found ) TO failed-purchaserequest.        CONTINUE.      ENDIF.      APPEND VALUE #( %tky               = key-%tky                      RequestID          = head-request_id                      Description        = head-description                      Requester          = head-requester                      Status             = head-status                      TotalAmount        = head-total_amount                      CurrencyCode       = head-currency_code                      LocalLastChangedAt = head-local_last_changed_at )  " needed for ETag checks        TO result.    ENDLOOP.  ENDMETHOD. " ===== lhc_PurchaseRequest: CREATE BY ASSOCIATION (items) =====  METHOD cba_Item.    DATA now TYPE timestampl.    GET TIME STAMP FIELD now.     LOOP AT entities_cba INTO DATA(parent).      DATA(head) = lcl_buffer=>get_head( parent-RequestUUID ).      IF head IS INITIAL OR head-change = lcl_buffer=>flag-deleted.        APPEND VALUE #( %tky = parent-%tky                        %fail-cause = if_abap_behv=>cause-not_found ) TO failed-purchaserequest.        CONTINUE.      ENDIF.       LOOP AT parent-%target INTO DATA(item).   " item keys come from earlynumbering_cba_Item        INSERT VALUE lcl_buffer=>ty_item(                 item_uuid             = item-ItemUUID                 parent_uuid           = parent-RequestUUID                 item_no               = item-ItemNo                 item_text             = item-ItemText                 quantity              = item-Quantity                 unit                  = item-Unit                 price                 = item-Price                 currency_code         = item-CurrencyCode                 local_last_changed_at = now                 change                = lcl_buffer=>flag-created )          INTO TABLE lcl_buffer=>items.      ENDLOOP.    ENDLOOP.  ENDMETHOD. " ===== Production note: prefetch instead of one SELECT per key =====" IF keys IS NOT INITIAL."   SELECT * FROM zpr_head"     FOR ALL ENTRIES IN @keys"     WHERE request_uuid = @keys-RequestUUID"     INTO TABLE @DATA(db_rows)." ENDIF." ...store db_rows in a read cache used by get_head

Real project scenario

An unmanaged wrapper for a custom asset register went live, and within a day users reported that editing an asset's description wiped its location, cost center and responsible person. The update handler did "MOVE-CORRESPONDING entity TO db_row" and saved. Fiori sent a PATCH with only the Description field, so every other field arrived empty and overwrote real data.
The fix was a field-by-field copy guarded by %control. The team also wrote a regression test: update one field through EML with only that field in the FIELDS list, then read the instance and assert all other fields are unchanged.

A second defect found in the same review: delete called the legacy delete function directly in the handler. When another instance in the same mass-delete request failed, the first one was already gone. Deletion was moved to the saver.

Common mistakes

  • MOVE-CORRESPONDING / CORRESPONDING from the entity to the row without checking %control — PATCH requests then blank out unsent fields.
  • Processing only the first row of entities or keys.
  • Calling the legacy save, update or delete API from a handler instead of the saver.
  • Forgetting the create-then-delete case and sending a delete for a record that never existed to the legacy API.
  • Read method that skips unknown keys silently instead of returning FAILED.
  • Not returning the ETag field in the read result, causing ETag checks to fail.
  • Business validations inside create, which break draft (an empty draft is a valid start).
  • One SELECT per key inside the loop in production code.

Best practices

  • Treat every update as PATCH: copy fields only when %control is on.
  • Always set %fail-cause so OData returns the correct HTTP status.
  • Keep flags consistent: C stays C after updates; C + delete = removed; database row + delete = D.
  • Make the read method the single transactional read path and have it use the buffer.
  • Prefetch all needed database rows once per handler call.
  • Write EML regression tests for PATCH behavior and for create-then-delete.

Interview angle

Classic senior question: "What is %control and why is it important in unmanaged RAP?" Answer: it flags which fields the consumer actually supplied, so an update changes only those; without it, PATCH requests overwrite unsent fields with initial values. Managed RAP handles this for you.
Also expect: "What do you return from a handler?" (MAPPED, FAILED with %fail-cause, REPORTED), "Why must the read method use the buffer?" (EML reads and ETag checks must see uncommitted changes), "Why not call the legacy API in the handler?" (atomicity — only the saver runs after all instances passed checks). Mentioning create-then-delete handling is a strong signal of real experience.

Practice this lesson

Quick check

1. In an unmanaged update handler, what does entity-%control-Description = if_abap_behv=>mk-on tell you?

2. An instance is created and then deleted in the same transaction of an unmanaged BO. What should the delete handler do?

3. Which situation is the strongest reason to implement a RAP business object as unmanaged?

Interview questions

1. Users report that editing only the description of an asset in a new RAP app wipes its location and cost center. The BO is unmanaged. What is the bug and how do you fix it?

Answer it in your own words first, then compare.

2. In an unmanaged BO, a user changes a field and triggers an action in the same interaction. The action uses the old value, and the next save fails with 'data changed by another user'. What is the likely cause?

Answer it in your own words first, then compare.

Practice more with Interview AI →

Reference-verified: the steps are checked against official SAP documentation and tutorials, and object and field names in the sample code are checked for consistency. The code has not been compiled or run on an SAP system by ERPClimb. Syntax marked release-dependent varies by SAP release, so check it in your own system.

More lessons in this topic

Interview questions on this topic

Related SAP topics