Every SAP project eventually asks the same question — who changed that, and when? SAP Change Documents answer it automatically, writing a detailed field-level audit trail to CDHDR and CDPOS every time your custom data changes. No custom logging table, no triggers, no background jobs. In this first post of a two-part series we build a complete implementation from scratch — custom tables, SCDO setup, and an ABAP wrapper class that handles database operations and change document recording in a single, easy-to-call interface. Part 2 extends this into a full RAP Business Object with Fiori Elements UI.
This is Part 1 of a two-part series on SAP Change Documents. In this part, we cover the foundations — what change documents are, how they work, and how to implement them with custom Z tables and a clean ABAP wrapper class. In Part 2, SAP Change Document in RAP, we will extend this into a full RAP Business Object with Fiori Elements UI.
Introduction
If you have ever been asked the question — who changed that field, and when — you will immediately appreciate why change documents matter. In any serious SAP implementation, traceability is not a nice-to-have. It is a business requirement, often driven by audit, compliance, or simply the need to investigate data quality issues after the fact.
SAP provides a standard, reliable framework for exactly this purpose: Change Documents. Once set up, every create, update, and delete operation on your custom tables produces a detailed, timestamped record of exactly what changed, written automatically to two standard SAP tables — CDHDR and CDPOS. No custom logging table, no triggers, no background jobs. The framework handles it all.
In this blog series, we will walk through a complete implementation from scratch. We will use a realistic example — a customer master with header information and associated bank accounts — to show how change documents work in practice, and how to build a clean, reusable ABAP class that wraps both the database operations and the change document recording in a single, easy-to-call interface
What are Change Documents?
Change Documents are SAP’s built-in audit trail mechanism for custom business objects. When correctly implemented, every time a record in your custom table is created, modified, or deleted, SAP automatically captures:
- Who made the change — the SAP user name
- When the change was made — date and time
- Which transaction or program triggered the change
- Which fields changed — at field level
- What the old value was
- What the new value is
This information is stored across two standard SAP tables that you never need to create or manage yourself.
How does the Framework Work?
The change document framework does not intercept your database calls automatically. You have to call it explicitly. The framework works as follows:
Everything starts with the creation of the Change Document Object. This can be done in ADT or in transaction SCDO. Here, you define a named Change Document Object and assign your custom tables to it. Whether the field change will be tracked or not depends on the flag set in the Data Element. In ADT, as soon as you activate the object, it generates a class with a method WRITE. If you are using SAP GUI transaction(SCDO), you have the option to generate either a class or a function module.
Your Responsibility
The generated class method (WRITE) does one thing only — it compares the old and new versions of your data that you pass to it, and writes the differences to CDHDR and CDPOS. It does not touch your Z tables at all. That means your code is responsible for two things in the correct order:
- Write to your Z tables using
INSERT,MODIFY, orDELETE - Call the generated class method/FM, passing it both the old and new versions of the data
Both operations must happen within the same Logical Unit of Work, so they commit together. If the database update succeeds but the FM call fails, you want a rollback – not a situation where your data changed, but no change document was recorded.
This is the most important point to understand. The method cannot read old values from the database itself — by the time you call it, your MODIFY statement may have already overwritten the old data. You must read the old values from the database before making any changes, hold them in memory, and pass them to the FM alongside the new values.
In the case of a Managed RAP application, the call is handled automatically by the framework. We do have to specify the Change Document Object in the Behaviour Definition.
Checklist of Objects
| Object Type | Object Name | Auto-generated class with method WRITE |
|---|---|---|
| Table | ZZCUSTOMER | Header Table |
| Table | ZZCUSTOMER_BANK | Item Table |
| Change Document Object | ZZCUSTOMER | To log changes |
| Class | ZCL_ZZCUSTOMER_CHDO | Auto generated Class with method WRITE |
| Class | ZCL_CUSTOMER_CHANGE_WRAPPER | Class to wrap CREATE, UPDATE and DELETE methods |
Tables
We are going to work with two tables ZZCUSTOMER and ZZCUSTOMER_BANK. This is a typical header item table. I am going to work with UUID as key as I will extend these to the RAP application later. I have used SAP Data Elements for fields. These Data Elements for fields NAME, STREET, CITY, POSTAL_CODE, COUNTRY etc. have ‘Change Document Logging’ checked.
@EndUserText.label : 'Customer'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #RESTRICTED
define table zzcustomer {
key client : abap.clnt not null;
key customer_id : sysuuid_x16 not null;
name : ad_name1;
street : ad_street;
city : ad_city1;
postal_code : ad_pstcd1;
country : land1;
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_lastchange_tstmpl;
last_changed_at : abp_lastchange_tstmpl;
}
@EndUserText.label : 'Customer Bank Details'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #RESTRICTED
define table zzcustomer_bank {
key client : abap.clnt not null;
key bankid : sysuuid_x16 not null;
customer_id : sysuuid_x16;
bank_country : banks not null;
bank_key : bankk not null;
bank_account : bankn;
currency : waers;
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_lastchange_tstmpl;
last_changed_at : abp_lastchange_tstmpl;
}
Change Document Object
Create Change Document Object in ADT or in transaction SCDO (I’ve used ADT). Add tables ZZCUSTOMER and ZZCUSTOMER_BANK. Check ‘Log Multiple Changes’ in both tables. Activate. You will notice it will create a class (ZCL_ZZCUSTOMER_CHDO) and show it just under the Change Document.
Wrapper Class
This class acts as a combined repository and audit layer — all database INSERT, MODIFY, DELETE operations on ZZCUSTOMER and ZZCUSTOMER_BANK go through this class, which also records the change document automatically. The caller simply calls the method and then COMMIT WORK.
Definition
CLASS zcl_customer_change_wrapper DEFINITION
PUBLIC
FINAL
CREATE PUBLIC .
PUBLIC SECTION.
METHODS create
IMPORTING
is_hdr TYPE zzcustomer
it_dtl TYPE zzcustomerbank_t
RAISING
cx_sy_open_sql_error.
METHODS update
IMPORTING
is_hdr TYPE zzcustomer
it_dtl TYPE zzcustomerbank_t
RAISING
cx_sy_open_sql_error.
METHODS delete
IMPORTING
iv_customer_id TYPE zzcustomer-customer_id
RAISING
cx_sy_open_sql_error.
PROTECTED SECTION.
PRIVATE SECTION.
METHODS call_write_document
IMPORTING
iv_customer_id TYPE zzcustomer-customer_id
object_change_indicator TYPE if_chdo_object_tools_rel=>ty_cdchngindh
it_hdr_new TYPE zcl_zzcustomer_chdo=>tt_zzcustomer
it_hdr_old TYPE zcl_zzcustomer_chdo=>tt_zzcustomer OPTIONAL
it_dtl_new TYPE zcl_zzcustomer_chdo=>tt_zzcustomer_bank
it_dtl_old TYPE zcl_zzcustomer_chdo=>tt_zzcustomer_bank OPTIONAL.
METHODS read_current_customer
IMPORTING
iv_customer_id TYPE zzcustomer-customer_id
RETURNING
VALUE(rs_hdr) TYPE zzcustomer.
METHODS read_current_customer_bank
IMPORTING
iv_customer_id TYPE zzcustomer-customer_id
RETURNING
VALUE(rt_dtl) TYPE zzcustomerbank_t.
ENDCLASS.
Class Implementation
Method CREATE
METHOD create.
SELECT SINGLE customer_id FROM zzcustomer
WHERE customer_id = @is_hdr-customer_id
INTO @DATA(lv_exists).
IF sy-subrc = 0.
RAISE EXCEPTION TYPE cx_sy_open_sql_error.
ENDIF.
DATA it_hdr_new TYPE zcl_zzcustomer_chdo=>tt_zzcustomer .
DATA it_dtl_new TYPE zcl_zzcustomer_chdo=>tt_zzcustomer_bank .
APPEND INITIAL LINE TO it_hdr_new ASSIGNING FIELD-SYMBOL(<chgdoc_hdr>).
MOVE-CORRESPONDING is_hdr TO <chgdoc_hdr> .
<chgdoc_hdr>-kz = 'I' .
LOOP AT it_dtl ASSIGNING FIELD-SYMBOL(<new_record>) .
APPEND INITIAL LINE TO it_dtl_new ASSIGNING FIELD-SYMBOL(<chgdoc_dtl>) .
MOVE-CORRESPONDING <new_record> TO <chgdoc_dtl> .
<chgdoc_dtl>-kz = 'I' .
ENDLOOP.
INSERT zzcustomer FROM @is_hdr.
INSERT zzcustomer_bank FROM TABLE @it_dtl.
" Change doc
call_write_document(
object_change_indicator = 'U'
iv_customer_id = is_hdr-customer_id
it_hdr_new = it_hdr_new
it_dtl_new = it_dtl_new ).
ENDMETHOD.
The object_change_indicator was passed value ‘U’ instead of ‘I’ to align with how the change document works in RAP.
Method UPDATE
METHOD update.
" Read old state from DB before modifying
DATA(ls_hdr_old) = read_current_customer( is_hdr-customer_id ).
DATA(lt_dtl_old) = read_current_customer_bank( is_hdr-customer_id ).
DATA it_hdr_old TYPE zcl_zzcustomer_chdo=>tt_zzcustomer .
DATA it_dtl_old TYPE zcl_zzcustomer_chdo=>tt_zzcustomer_bank .
DATA it_hdr_new TYPE zcl_zzcustomer_chdo=>tt_zzcustomer .
DATA it_dtl_new TYPE zcl_zzcustomer_chdo=>tt_zzcustomer_bank .
APPEND ls_hdr_old TO it_hdr_old .
APPEND LINES OF lt_dtl_old TO it_dtl_old .
APPEND INITIAL LINE TO it_hdr_new ASSIGNING FIELD-SYMBOL(<chgdoc_hdr>).
MOVE-CORRESPONDING is_hdr TO <chgdoc_hdr> .
<chgdoc_hdr>-kz = 'U' .
<chgdoc_hdr>-client = sy-mandt .
LOOP AT it_dtl ASSIGNING FIELD-SYMBOL(<new_record>) .
APPEND INITIAL LINE TO it_dtl_new ASSIGNING FIELD-SYMBOL(<chgdoc_dtl>) .
MOVE-CORRESPONDING <new_record> TO <chgdoc_dtl> .
<chgdoc_dtl>-kz = 'U' .
<chgdoc_dtl>-client = sy-mandt .
ENDLOOP.
MODIFY zzcustomer FROM @is_hdr.
MODIFY zzcustomer_bank FROM TABLE @it_dtl.
" Change doc
call_write_document(
object_change_indicator = 'U'
iv_customer_id = is_hdr-customer_id
it_hdr_new = it_hdr_new
it_hdr_old = it_hdr_old
it_dtl_new = it_dtl_new
it_dtl_old = it_dtl_old ).
ENDMETHOD.
Method DELETE
METHOD delete.
"Read old state from DB before modifying
DATA(ls_hdr_old) = read_current_customer( iv_customer_id ).
DATA(lt_dtl_old) = read_current_customer_bank( iv_customer_id ).
DATA it_hdr_old TYPE zcl_zzcustomer_chdo=>tt_zzcustomer .
DATA it_dtl_old TYPE zcl_zzcustomer_chdo=>tt_zzcustomer_bank .
DATA it_hdr_new TYPE zcl_zzcustomer_chdo=>tt_zzcustomer .
DATA it_dtl_new TYPE zcl_zzcustomer_chdo=>tt_zzcustomer_bank .
APPEND ls_hdr_old TO it_hdr_old .
APPEND LINES OF lt_dtl_old TO it_dtl_old .
"Delete detail first, then header (referential integrity)
DELETE FROM zzcustomer WHERE customer_id = @iv_customer_id.
DELETE FROM zzcustomer_bank WHERE customer_id = @iv_customer_id.
" Change doc
call_write_document(
object_change_indicator = 'D'
iv_customer_id = iv_customer_id
it_hdr_new = it_hdr_new
it_hdr_old = it_hdr_old
it_dtl_new = it_dtl_new
it_dtl_old = it_dtl_old ).
ENDMETHOD.
Private helper methods CALL_WRITE_DOCUMENT, READ_CURRENT_CUSTOMER, READ_CURRENT_CUSTOMER_BANK
METHOD call_write_document.
TRY .
zcl_zzcustomer_chdo=>write(
EXPORTING
objectid = CONV cdobjectv( iv_customer_id )
tcode = sy-tcode
utime = sy-uzeit
udate = sy-datum
username = sy-uname
object_change_indicator = object_change_indicator
xzzcustomer = it_hdr_new
yzzcustomer = it_hdr_old
upd_zzcustomer = 'U'
xzzcustomer_bank = it_dtl_new
yzzcustomer_bank = it_dtl_old
upd_zzcustomer_bank = 'U'
IMPORTING
changenumber = DATA(change_number)
).
CATCH cx_chdo_write_error.
ENDTRY.
ENDMETHOD.
METHOD read_current_customer_bank.
SELECT * FROM zzcustomer_bank
WHERE customer_id = @iv_customer_id
INTO TABLE @rt_dtl.
ENDMETHOD.
METHOD read_current_customer.
SELECT SINGLE * FROM zzcustomer
WHERE customer_id = @iv_customer_id
INTO @rs_hdr.
ENDMETHOD.
Usage Example
Testing: Create a New Customer
DATA: current_time TYPE timestampl,
customer_id TYPE zzcustomer-customer_id,
bank_id TYPE zzcustomer_bank-bankid.
GET TIME STAMP FIELD current_time.
"Get UUIDs for keys
customer_id = cl_system_uuid=>create_uuid_x16_static( ).
bank_id = cl_system_uuid=>create_uuid_x16_static( ).
DATA(lo_cust) = NEW zcl_customer_change_wrapper( ).
lo_cust->create(
is_hdr = VALUE zzcustomer(
customer_id = customer_id
name = 'ABC Company'
street = 'Long Street'
city = 'London'
postal_code = 'EC10 5GH'
country = 'GB'
local_created_by = sy-uname
local_created_at = current_time
local_last_changed_by = sy-uname
local_last_changed_at = current_time
last_changed_at = current_time
)
it_dtl = VALUE zzcustomerbank_t(
( bankid = bank_id
customer_id = customer_id
bank_country = 'GB'
bank_key = '123456'
bank_account = '123456789'
currency = 'GBP'
local_created_by = sy-uname
local_created_at = current_time
local_last_changed_by = sy-uname
local_last_changed_at = current_time
last_changed_at = current_time
)
)
).
COMMIT WORK.
Check table ZZCUSTOMER, ZZCUSTOMER_BANK, CDHDR and CDPOS.
Testing: Update Customer
DATA: current_time TYPE timestampl.
GET TIME STAMP FIELD current_time.
DATA(lo_cust) = NEW zcl_customer_change_wrapper( ).
"Get customer_id and bank_id UUIDs from table ZZCUSTOMER and ZZCUSTOMER_BANK
lo_cust->update(
is_hdr = VALUE zzcustomer(
customer_id = '005056A3C6531FD18AECAF7C7E10D978'
name = 'XYZ Company' "<< Change
street = 'New Long Street' "<< Change
city = 'London'
postal_code = 'EC10 5GH'
country = 'GB'
local_created_by = sy-uname
local_created_at = current_time
local_last_changed_by = sy-uname
local_last_changed_at = current_time
last_changed_at = current_time
)
it_dtl = VALUE #(
( bankid = '005056A3C6531FD18AECAF7C7E10F978'
customer_id = '005056A3C6531FD18AECAF7C7E10D978'
bank_country = 'GB'
bank_key = '30-40-50' "<< Change
bank_account = '123456789'
currency = 'GBP'
local_created_by = sy-uname
local_created_at = current_time
local_last_changed_by = sy-uname
local_last_changed_at = current_time
last_changed_at = current_time
)
)
).
COMMIT WORK.
Check table ZZCUSTOMER, ZZCUSTOMER_BANK, CDHDR and CDPOS.
Testing: Delete Customer
DATA(lo_cust) = NEW zcl_customer_change_wrapper( ).
"Get customer_id UUID from table ZZCUSTOMER
lo_cust->delete( iv_customer_id = '005056A3C6531FD18AECAF7C7E10D978' ) .
COMMIT WORK.
Check table ZZCUSTOMER, ZZCUSTOMER_BANK, CDHDR and CDPOS.
One Reply to “How to Implement SAP Change Document”