RAP: soft delete with managed and unmanaged save
By default, RAP managed performs a physical DELETE at save time: the record disappears from the database, full stop. But in many real-world scenarios — master data, customizing, entities referenced by logs — the row cannot simply vanish: you need traceability, you need to be able to restore, you need references to stay intact. The answer is logical deletion (soft delete): the row stays in the DB, marked with an is_deleted flag, and disappears only from reads.
In this article I apply the full pattern to a sample master-data entity: from the table structure to the interface view filter, all the way to the save_modified saver that intercepts the framework's DELETE and turns it into an UPDATE. Everything stays within managed: the framework keeps handling locks, ETags, draft and validations — only persistence moves to us, via with unmanaged save.
When to use logical deletion
This is not a pattern to apply everywhere. The rule of thumb I follow:
- Master data and customizing where traceability matters and history cannot be lost.
- Entities referenced by log tables or other application tables: a physical
DELETEwould break the references. - Cases where deletion must be blockable by preconditions (record already deleted, open dependencies).
If a physical DELETE is fine — transient data, staging, entities with no references — stay on plain managed with delete; in the BDEF, no custom saver. Less code, less maintenance.
Step 1 — The table: flag + deletion timestamp
The only field that is truly required is the is_deleted flag: it drives the read filter and the validations. Recording when the deletion happened is not mandatory, but it is strongly recommended.
To reuse the same fields across every table that needs soft delete, I define them once in a DDIC include:
ZRAP_SG_DELETED_LOG — logical deletion:
@EndUserText.label : 'Audit: logical deletion'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
define structure zrap_sg_deleted_log {
is_deleted : zrap_sg_deleted; // flag: the only truly required field
deleted_at : timestampl; // UTC timestamp: recommended
}
The master-data table declares its key and business fields, then includes the structure:
@EndUserText.label : 'Group master data'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #ALLOWED
define table zrap_sg_group {
key mandt : mandt not null;
key group_id : zrap_sg_group_id not null;
group_name : zrap_sg_group_name;
include zrap_sg_deleted_log; // is_deleted + deleted_at
}
Every new entity that needs soft delete reuses exactly the same fields and types, with no duplication.
Step 2 — Interface view: the filter that hides deleted records
The heart of logical deletion on the read side is a single line: where is_deleted <> 'X'. Marked records no longer show up in any application query, while remaining in the DB.
@AbapCatalog.viewEnhancementCategory: [#NONE]
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Group - Interface View'
@Metadata.ignorePropagatedAnnotations: true
@ObjectModel.usageType: { serviceQuality: #A, sizeCategory: #S, dataClass: #CUSTOMIZING }
define root view entity ZRAP_SG_I_Group
as select from zrap_sg_group as GRP
{
// ── Key ─────────────────────────────────────────────────
key GRP.group_id as GroupId,
// ── Business fields ─────────────────────────────────────
GRP.group_name as GroupName,
// ── Logical deletion ────────────────────────────────────
@Semantics.booleanIndicator: true
GRP.is_deleted as IsDeleted,
GRP.deleted_at as DeletedAt
}
where
GRP.is_deleted <> 'X' // logically deleted records vanish from reads
Filter direction:
<> 'X'is the standard case — show active records only. If you need a "recycle bin" view for restore scenarios, create a separate view with the inverted condition (= 'X'). Mixing the two logics in the same consumption view is not recommended.
Step 3 — Behavior Definition: with unmanaged save
Three elements make the delete "logical":
with unmanaged save— the framework keeps orchestrating the transaction, but we write the physical DML ourselves in the saver.delete ( precheck );— enables a precheck to reject non-admissible deletes before they reach the save.field ( readonly )on the system fields — the client cannot set them; onlysave_modifiedwrites them.
managed implementation in class ZBP_RAP_SG_GROUP unique;
strict ( 2 );
define behavior for ZRAP_SG_I_Group alias Group
with unmanaged save
lock master
authorization master ( global )
{
// ── Standard operations ─────────────────────────────────────
create;
update;
delete ( precheck );
// ── Validations ─────────────────────────────────────────────
validation validateIsDeleted on save { create; update; }
// ── Field control ───────────────────────────────────────────
field ( readonly : update ) GroupId;
// System fields: written ONLY by save_modified
field ( readonly ) IsDeleted, DeletedAt;
// ── Mapping ─────────────────────────────────────────────────
mapping for zrap_sg_group corresponding
{
GroupId = group_id;
GroupName = group_name;
}
}
With with unmanaged save, the mapping only serves to materialize incoming data: the physical DML is written by save_modified anyway. The readonly fields don't enter the mapping because they never come from the client.
Step 4 — precheckDelete: blocking non-admissible deletes
The precheck rejects the delete when it makes no sense or is not allowed — here, a record already logically deleted; this is also where you add the other business preconditions (open dependencies, incompatible status). The check is a bulk one: a single SELECT for all keys, then BINARY SEARCH in the loop.
METHOD precheckdelete.
IF keys IS INITIAL.
RETURN.
ENDIF.
" Bulk check: records already logically deleted
SELECT group_id
FROM zrap_sg_group
FOR ALL ENTRIES IN @keys
WHERE group_id = @keys-GroupId
AND is_deleted = @abap_true
INTO TABLE @DATA(lt_deleted).
SORT lt_deleted BY group_id.
" Application error for every record already deleted
LOOP AT keys ASSIGNING FIELD-SYMBOL(<ls_key>).
READ TABLE lt_deleted TRANSPORTING NO FIELDS
WITH KEY group_id = <ls_key>-GroupId
BINARY SEARCH.
IF sy-subrc = 0.
APPEND VALUE #(
%key = <ls_key>-%key
%fail-cause = if_abap_behv=>cause-unspecific
) TO failed-group.
APPEND VALUE #(
%key = <ls_key>-%key
%msg = new_message(
id = 'ZRAP_SG'
number = '001'
severity = if_abap_behv_message=>severity-error
v1 = <ls_key>-GroupId )
) TO reported-group.
ENDIF.
ENDLOOP.
ENDMETHOD.
If the precheck fails, the framework discards the instance before the save: no write happens, and the client receives a clean application error.
Step 5 — save_modified: the DELETE that becomes an UPDATE
This is where the transformation happens. The DELETE request reaches the saver in the delete-group structure, but it is never executed as a DELETE FROM: I re-read the full row (the framework only passes the keys), set flag and timestamp, and execute an UPDATE.
CLASS lsc_group DEFINITION INHERITING FROM cl_abap_behavior_saver.
PROTECTED SECTION.
METHODS save_modified REDEFINITION.
ENDCLASS.
CLASS lsc_group IMPLEMENTATION.
METHOD save_modified.
"================================================================
" DELETE: logical deletion — is_deleted = 'X' + timestamp
"================================================================
IF delete-group IS NOT INITIAL.
" Re-read the full row from DB: the framework passes keys only
SELECT *
FROM zrap_sg_group
FOR ALL ENTRIES IN @delete-group
WHERE group_id = @delete-group-GroupId
INTO TABLE @DATA(lt_delete).
LOOP AT lt_delete ASSIGNING FIELD-SYMBOL(<ls_del>).
<ls_del>-is_deleted = abap_true.
GET TIME STAMP FIELD <ls_del>-deleted_at.
ENDLOOP.
UPDATE zrap_sg_group FROM TABLE lt_delete.
ENDIF.
ENDMETHOD.
ENDCLASS.
In the real behavior pool, the same save_modified also handles create- (INSERT) and update- (merge of the fields from %control + UPDATE): same "read → set system fields → DML" scheme.
The final piece: blocking changes after deletion
A logically deleted record must no longer be modifiable — otherwise it would stay writable while being "deleted". The validation declared in the BDEF guarantees this:
validation validateIsDeleted on save { create; update; }
The implementation reads the state from the DB and rejects with an application error any UPDATE on records with is_deleted = 'X'. Together with the delete precheck, it closes the loop: a deleted record cannot be re-deleted and cannot be modified.
The end-to-end flow, recapping:
- OData DELETE on the entity → the managed framework collects the key.
precheckDeletechecks the preconditions. If it fails → error, no write.save_modifiedinterceptsdelete-group, re-reads the row, setsis_deleted+deleted_at, executes theUPDATE.- The interface view (
where is_deleted <> 'X') excludes the record: gone from the app, present in the DB. - Any subsequent UPDATE is blocked by
validateIsDeleted.
In summary
| Object | Key element |
|---|---|
| Table | include zrap_sg_deleted_log; — required flag + recommended UTC timestamp |
| Interface view | where is_deleted <> 'X' |
| Behavior Definition | with unmanaged save, delete ( precheck ), field ( readonly ) on system fields |
| Behavior pool | lsc_ saver with save_modified REDEFINITION; DELETE → UPDATE of the flag |
| Post-delete blocking | validation validateIsDeleted on save { create; update; } |
The strength of this pattern is that it stays inside the managed model: locks, ETags, draft, validations and messaging keep working as usual — the only thing that changes is the meaning of DELETE. The client issues a standard OData DELETE, the application behaves as if the record were gone, and the database keeps everything: audit, restore and referential integrity included.