Unmanaged RAP Part 2: Architecture — CDS, BDEF and Transactional Buffer
Set up the complete unmanaged RAP architecture for a brownfield application: CDS views for queries, the full unmanaged behavior definition, the behavior pool layout, and the transactional buffer class you own.
Explanation
📌 KEY POINTS
✅ The transactional buffer holds all changes until save — you build it in unmanaged RAP.
✅ Keep the buffer in a singleton class, keyed by %tky / %pid.
✅ Read methods must return buffered data first, then the database.
⚠️ The ETag field must change on every update, or users get false conflicts.
▶ WHERE WE ARE
Part 1 explained when to use unmanaged RAP and implemented locking, numbering and message mapping. Now you build the full skeleton that all remaining code plugs into.
━━━━━━━━━━━━━━━━━━━━
▶ THE BIG PICTURE — FOUR LAYERS YOU WIRE TOGETHER
- CDS view entities (ZR_PurchReqU, ZR_PurchReqItemU) — the data model and the query side. When a Fiori list report asks for "all requests of status S", RAP answers directly from these CDS views with SQL. Your ABAP code is not involved in queries.
- Behavior definition — the contract: which operations exist, locks, ETags, authorization, numbering.
- Behavior pool ZBP_R_PURCHREQ_U — your implementation, with three kinds of local classes:
- lhc_ handler classes — the interaction phase: create, update, delete, read, lock, numbering, actions. They only change the buffer.
- lsc_ saver class — the save sequence: finalize, check_before_save, save, cleanup. Only this touches the legacy API's save.
- lcl_buffer — the transactional buffer: static tables holding what was created, changed and deleted in this transaction.
- Legacy API — the existing function group or class that validates and saves purchase requests.
THE DATA FLOW OF ONE "SAVE" CLICK
User edits in Fiori → OData request → RAP calls lock → RAP calls update in lhc_PurchaseRequest → update reads the current state (buffer first, then database), applies only the sent fields, and stores the result in lcl_buffer → user clicks Save → RAP calls the saver: finalize → check_before_save (legacy checks) → save (legacy save, in the same LUW) → framework commit → cleanup clears the buffer.
━━━━━━━━━━━━━━━━━━━━
▶ QUERY PATH VS TRANSACTIONAL READ PATH
This distinction confuses many developers:
- Query path — list reports, filters, search, $expand for display. Served by the CDS views via SQL. It sees committed database data only.
- Transactional read path — EML READ ENTITIES, reads inside determinations/validations, ETag checks before update, and reads after an action. Served by YOUR read method, which must look into the buffer first so it returns uncommitted changes of the current transaction.
If your read method ignores the buffer, an update followed by an action in the same request works on stale data, and ETag checks fail after changes.
━━━━━━━━━━━━━━━━━━━━
▶ THE CDS MODEL
The CDS views look exactly like the managed ones: a root view with a composition and a child view with an association to parent. Admin-field @Semantics annotations help the UI but, in unmanaged RAP, nobody fills the fields automatically — your code (or the legacy API) must set them, especially the ETag field LocalLastChangedAt on every change.
━━━━━━━━━━━━━━━━━━━━
▶ THE FULL BEHAVIOR DEFINITION — WHAT IS DIFFERENT FROM MANAGED
- "unmanaged implementation in class ... unique" instead of "managed".
- No "persistent table", no "draft table" unless you add draft (draft in unmanaged is possible, but the draft tables are still managed by RAP; only the active save is yours).
- "early numbering" on each entity that you number yourself, which generates earlynumbering_create and, for children, earlynumbering_cba_Item.
- Every declared operation becomes a method you must implement: create, update, delete, read, rba_ (read by association) and cba_ (create by association), lock.
- Child entities with "lock dependent by _Request" need the read-by-association from child to parent (rba_Request) so RAP can find the lock master.
━━━━━━━━━━━━━━━━━━━━
▶ THE TRANSACTIONAL BUFFER — DESIGN RULES
- It lives as static attributes of a local class, so all handler and saver classes share it within the transaction.
- Each entry stores the full row plus a change indicator: C (created), U (updated), D (deleted).
- One method "get_head" returns an instance's current state: buffer first; if not buffered, read the database. Every handler uses it.
- Created then deleted in the same transaction → remove from buffer entirely (never send to the legacy API).
- Created then updated → stays C, with the updated values.
- The saver's cleanup method clears everything, so nothing leaks into the next transaction.
━━━━━━━━━━━━━━━━━━━━
▶ STEP BY STEP
- Create ZR_PurchReqU and ZR_PurchReqItemU (same as the managed views in the Managed series, Part 1, with the U suffix).
- Create the BDEF from the code example.
- Use the quick fix to generate ZBP_R_PURCHREQ_U. Note the generated local classes: lhc_PurchaseRequest, lhc_Item and lsc_ZR_PurchReqU.
- Add lcl_buffer at the top of the local types include (Local Types tab), before the handler classes.
- Activate. The object compiles with empty methods; Parts 3 and 4 fill them.
━━━━━━━━━━━━━━━━━━━━
▶ WHAT YOU HAVE AFTER PART 2
A compiling unmanaged business object with a clear separation: handlers change the buffer, the saver talks to the legacy API, CDS serves queries. Part 3 implements the CRUD and read methods.
Code example
// ===== Behavior definition ZR_PurchReqU (full) =====unmanaged implementation in class zbp_r_purchreq_u unique;strict ( 2 ); define behavior for ZR_PurchReqU alias PurchaseRequestlock masterauthorization master ( global, instance )etag master LocalLastChangedAtearly numbering{ create; update; delete; association _Item { create; } field ( readonly ) RequestUUID, RequestID, Status, LocalLastChangedAt; field ( mandatory ) Description, CurrencyCode; action approve result [1] $self;} define behavior for ZR_PurchReqItemU alias Itemlock dependent by _Requestauthorization dependent by _Requestetag master LocalLastChangedAtearly numbering{ update; delete; association _Request; field ( readonly ) ItemUUID, RequestUUID, LocalLastChangedAt; field ( mandatory ) Quantity, Unit;} " ===== Behavior pool ZBP_R_PURCHREQ_U - Local Types: the transactional buffer =====CLASS lcl_buffer DEFINITION FINAL CREATE PRIVATE. PUBLIC SECTION. CONSTANTS: BEGIN OF flag, created TYPE c LENGTH 1 VALUE 'C', updated TYPE c LENGTH 1 VALUE 'U', deleted TYPE c LENGTH 1 VALUE 'D', END OF flag. TYPES BEGIN OF ty_head. INCLUDE TYPE zpr_head. TYPES change TYPE c LENGTH 1. TYPES END OF ty_head. TYPES BEGIN OF ty_item. INCLUDE TYPE zpr_item. TYPES change TYPE c LENGTH 1. TYPES END OF ty_item. CLASS-DATA heads TYPE SORTED TABLE OF ty_head WITH UNIQUE KEY request_uuid. CLASS-DATA items TYPE SORTED TABLE OF ty_item WITH UNIQUE KEY item_uuid. "! Current state of a request: buffer first, database second CLASS-METHODS get_head IMPORTING uuid TYPE sysuuid_x16 RETURNING VALUE(result) TYPE ty_head. CLASS-METHODS clear.ENDCLASS. CLASS lcl_buffer IMPLEMENTATION. METHOD get_head. READ TABLE heads INTO result WITH TABLE KEY request_uuid = uuid. IF sy-subrc = 0. RETURN. " includes uncommitted changes ENDIF. SELECT SINGLE * FROM zpr_head WHERE request_uuid = @uuid INTO CORRESPONDING FIELDS OF @result. " result stays initial if not found -> caller reports NOT_FOUND ENDMETHOD. METHOD clear. CLEAR: heads, items. ENDMETHOD. ENDCLASS. " ===== Generated class skeleton (what each class is for) =====" lhc_PurchaseRequest -> create, update, delete, read, lock," earlynumbering_create, earlynumbering_cba_Item," cba_Item, rba_Item, approve, authorizations" lhc_Item -> update, delete, read, rba_Request" lsc_ZR_PurchReqU -> finalize, check_before_save, save, cleanup, cleanup_finalize"" Rule: lhc_ classes change ONLY lcl_buffer." Only lsc_ touches the legacy save API." Nobody calls COMMIT WORK.Real project scenario
In an unmanaged service-order wrapper, the read method selected directly from the database table and ignored the buffer. Everything looked fine in simple tests. In production, users changed the priority and immediately clicked a "Release" action in the same Fiori interaction. The action read the order through EML, got the old priority from the database, and released it with the wrong priority routing.
A second symptom appeared with ETags: after any change in the same transaction, the next change failed with "data was changed by another user", because the ETag was compared against the unchanged database value.
Both bugs came from one design error. The fix was a single get-method on the buffer class ("buffer first, database second") that every handler and the read method used. After that, the team made "no direct SELECT on the persistence tables in handler methods" a code-review rule.
Common mistakes
- Confusing the query path (CDS, database only) with the transactional read path (your read method, buffer first).
- Read method that selects from the database only, ignoring buffered changes.
- Handler methods calling the legacy save API directly instead of writing to the buffer — this breaks atomicity when a later instance fails.
- Forgetting to set the ETag field on every change; in unmanaged RAP nothing sets it for you.
- Buffer declared as instance attributes of a handler class — each handler instance then sees a different buffer.
- Not clearing the buffer in cleanup, so data leaks into the next transaction in the same session.
- Forgetting rba_Request on the child, so RAP cannot resolve the lock master for item changes.
Best practices
- Keep a strict split: handlers → buffer, saver → legacy API, CDS → queries.
- One buffer class with static tables and a single "buffer first, then database" read helper.
- Store a change indicator (C/U/D) per row and handle create-then-delete by removing the row.
- Set LocalLastChangedAt (and other admin fields) in the handler whenever you change an instance.
- Put lcl_buffer in the Local Types include so all local classes can use it.
- Draw the architecture (four layers, two paths) for your team before writing code — it prevents most unmanaged bugs.
Interview angle
Interviewers ask: "What classes does an unmanaged behavior pool contain and what does each do?", "What is the transactional buffer and why do you need it?", "How are list queries served in unmanaged RAP?"
Strong answer: handler classes run in the interaction phase and only update a transactional buffer; the saver class runs the save sequence and is the only place that calls the legacy save; queries are answered by the CDS views directly, while EML reads and ETag checks go through the read method, which must return buffered state. Mention the C/U/D flags and the create-then-delete case, and why the buffer must be static and cleared in cleanup. This shows you have actually built one, not just read about it.
Practice this lesson
Quick check
1. In an unmanaged business object, how is a Fiori list report query with filters answered?
2. Why is the transactional buffer in an unmanaged behavior pool usually implemented with static attributes of a local class?
3. Which situation is the strongest reason to implement a RAP business object as unmanaged?
Interview questions
1. Explain the transactional buffer in unmanaged RAP. How do the query path and the transactional read path differ, and what goes wrong if your read method ignores the buffer?
Answer it in your own words first, then compare.
2. A 15-year-old custom purchase request transaction must get a Fiori app without losing its function-group logic. Walk through your RAP design.
Answer it in your own words first, then compare.
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.