How to Implement SAP Change Document

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:

  1. Write to your Z tables using INSERT, MODIFY, or DELETE
  2. 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 TypeObject NameAuto-generated class with method WRITE
TableZZCUSTOMERHeader Table
TableZZCUSTOMER_BANKItem Table
Change Document ObjectZZCUSTOMERTo log changes
ClassZCL_ZZCUSTOMER_CHDOAuto generated Class with method WRITE
ClassZCL_CUSTOMER_CHANGE_WRAPPERClass 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

Leave a Reply