If you've ever maintained an approval workflow in SAP — purchase orders, leave requests, credit limit checks — you've probably seen the same ugly pattern repeated: a tower of nested IF/ELSEIF blocks, each one checking a condition and calling a different handler. It works, until business rules change. Then you're untangling a mess at 11pm before a go-live.
The ABAP chain of responsibility pattern solves exactly this. In this article I'll show you how to implement it properly, combine it with the State and Iterator patterns, and wire everything together into a clean, extensible workflow engine you'll actually want to maintain.
This is Part 5 of our ABAP OOP Design Patterns series. If you missed earlier parts, check out Part 3 covering Strategy, Command and Template Method and Part 2 on Factory, Observer and Decorator.
What Is the ABAP Chain of Responsibility Pattern?
Chain of Responsibility is a behavioral design pattern where you build a chain of handler objects. A request travels down the chain until one handler processes it — or the chain ends. Each handler decides: do I handle this, or do I pass it along?
The key insight is that the sender doesn't know which handler will process its request. That decoupling is what gives you flexibility when business rules evolve.
Classic SAP use cases:
- Purchase order approval (team lead → manager → director based on amount)
- Credit limit validation (local credit → global credit → finance controller)
- Document routing (automatic posting → manual review → rejection)
- Exception handling escalation (retry → fallback → alert)
Building the Chain: Interfaces and Abstract Classes
Start with a handler interface. Every link in the chain must implement it:
INTERFACE zif_approval_handler.
METHODS:
set_next
IMPORTING io_handler TYPE REF TO zif_approval_handler,
handle
IMPORTING is_request TYPE zs_approval_request
RETURNING VALUE(rv_handled) TYPE abap_bool.
ENDINTERFACE.
Now an abstract base class that handles the chain wiring so you don't repeat it in every concrete handler:
CLASS zcl_approval_handler_base DEFINITION ABSTRACT PUBLIC.
PUBLIC SECTION.
INTERFACES zif_approval_handler.
PROTECTED SECTION.
DATA mo_next TYPE REF TO zif_approval_handler.
METHODS handle_next
IMPORTING is_request TYPE zs_approval_request
RETURNING VALUE(rv_handled) TYPE abap_bool.
ENDCLASS.
CLASS zcl_approval_handler_base IMPLEMENTATION.
METHOD zif_approval_handler~set_next.
mo_next = io_handler.
ENDMETHOD.
METHOD handle_next.
IF mo_next IS BOUND.
rv_handled = mo_next->handle( is_request ).
ELSE.
rv_handled = abap_false. " nobody handled it
ENDIF.
ENDMETHOD.
ENDCLASS.
Concrete handlers extend the base class. Here's a team lead approval handler:
CLASS zcl_team_lead_handler DEFINITION PUBLIC
INHERITING FROM zcl_approval_handler_base FINAL.
PUBLIC SECTION.
CONSTANTS: c_limit TYPE p DECIMALS 2 VALUE '1000.00'.
METHODS zif_approval_handler~handle
IMPORTING is_request TYPE zs_approval_request
RETURNING VALUE(rv_handled) TYPE abap_bool.
ENDCLASS.
CLASS zcl_team_lead_handler IMPLEMENTATION.
METHOD zif_approval_handler~handle.
IF is_request-amount <= c_limit.
" Auto-approve and write approval log
INSERT VALUE #(
request_id = is_request-id
approved_by = 'TEAM_LEAD'
timestamp = sy-datum
) INTO TABLE gt_approval_log.
rv_handled = abap_true.
ELSE.
rv_handled = handle_next( is_request ).
ENDIF.
ENDMETHOD.
ENDCLASS.
The director handler at the end of the chain either approves or escalates to a human process:
CLASS zcl_director_handler DEFINITION PUBLIC
INHERITING FROM zcl_approval_handler_base FINAL.
PUBLIC SECTION.
CONSTANTS: c_limit TYPE p DECIMALS 2 VALUE '50000.00'.
METHODS zif_approval_handler~handle
IMPORTING is_request TYPE zs_approval_request
RETURNING VALUE(rv_handled) TYPE abap_bool.
ENDCLASS.
CLASS zcl_director_handler IMPLEMENTATION.
METHOD zif_approval_handler~handle.
IF is_request-amount <= c_limit.
rv_handled = abap_true.
ELSE.
" Escalate — trigger workflow task or send email
CALL FUNCTION 'SO_NEW_DOCUMENT_SEND_API1'
EXPORTING ...
rv_handled = abap_false. " still not auto-handled
ENDIF.
ENDMETHOD.
ENDCLASS.
Wiring the Chain at Runtime
Now the elegant part. You build the chain once, and the handlers take care of the rest:
DATA(lo_team_lead) = NEW zcl_team_lead_handler( ).
DATA(lo_manager) = NEW zcl_manager_handler( ).
DATA(lo_director) = NEW zcl_director_handler( ).
" Wire the chain
lo_team_lead->set_next( lo_manager ).
lo_manager->set_next( lo_director ).
" Fire the request at the head of the chain
DATA(lv_handled) = lo_team_lead->handle( ls_request ).
IF lv_handled = abap_false.
" Log unhandled request, raise alert
ENDIF.
Want to add a VP approval level between manager and director? Create one class, wire it in. Zero changes to existing handlers. That's the whole point.
Adding the State Pattern for Workflow Tracking
The chain tells you who handles a request, but not what state the workflow is in right now. For that you add the State pattern. The workflow object delegates behavior to a state object, which changes as the process progresses.
INTERFACE zif_workflow_state.
METHODS:
process
IMPORTING io_context TYPE REF TO zcl_workflow_context,
get_status
RETURNING VALUE(rv_status) TYPE string.
ENDINTERFACE.
CLASS zcl_state_pending DEFINITION PUBLIC FINAL.
PUBLIC SECTION.
INTERFACES zif_workflow_state.
ENDCLASS.
CLASS zcl_state_pending IMPLEMENTATION.
METHOD zif_workflow_state~process.
" Submit to chain, transition to 'in_review' or 'approved'
io_context->transition_to( NEW zcl_state_in_review( ) ).
ENDMETHOD.
METHOD zif_workflow_state~get_status.
rv_status = 'PENDING'.
ENDMETHOD.
ENDCLASS.
The context class holds the current state and delegates to it:
CLASS zcl_workflow_context DEFINITION PUBLIC.
PUBLIC SECTION.
METHODS:
constructor
IMPORTING io_initial_state TYPE REF TO zif_workflow_state,
process,
transition_to
IMPORTING io_new_state TYPE REF TO zif_workflow_state,
get_status
RETURNING VALUE(rv_status) TYPE string.
PRIVATE SECTION.
DATA mo_state TYPE REF TO zif_workflow_state.
ENDCLASS.
CLASS zcl_workflow_context IMPLEMENTATION.
METHOD constructor.
mo_state = io_initial_state.
ENDMETHOD.
METHOD process.
mo_state->process( me ).
ENDMETHOD.
METHOD transition_to.
mo_state = io_new_state.
ENDMETHOD.
METHOD get_status.
rv_status = mo_state->get_status( ).
ENDMETHOD.
ENDCLASS.
Now your workflow object always knows what phase it's in, and each state class encapsulates the logic for that phase. No more IF lv_status = 'PEND' OR lv_status = 'REVI'... scattered across your code.
Iterator Pattern for Processing Work Item Collections
Approval workflows rarely process one document at a time. You have a worklist — a collection of pending items. The Iterator pattern gives you a clean, uniform way to traverse collections without exposing the underlying data structure.
INTERFACE zif_worklist_iterator.
METHODS:
has_next
RETURNING VALUE(rv_result) TYPE abap_bool,
next
RETURNING VALUE(rs_item) TYPE zs_work_item.
ENDINTERFACE.
CLASS zcl_worklist_iterator DEFINITION PUBLIC FINAL.
PUBLIC SECTION.
INTERFACES zif_worklist_iterator.
METHODS constructor
IMPORTING it_items TYPE ztt_work_items.
PRIVATE SECTION.
DATA mt_items TYPE ztt_work_items.
DATA mv_index TYPE i VALUE 0.
ENDCLASS.
CLASS zcl_worklist_iterator IMPLEMENTATION.
METHOD constructor.
mt_items = it_items.
ENDMETHOD.
METHOD zif_worklist_iterator~has_next.
rv_result = xsdbool( mv_index < lines( mt_items ) ).
ENDMETHOD.
METHOD zif_worklist_iterator~next.
mv_index += 1.
READ TABLE mt_items INDEX mv_index INTO rs_item.
ENDMETHOD.
ENDCLASS.
Combining all three patterns, your batch workflow processor looks like this:
DATA(lo_iterator) = NEW zcl_worklist_iterator( lt_pending_items ).
WHILE lo_iterator->has_next( ) = abap_true.
DATA(ls_item) = lo_iterator->next( ).
" Create fresh workflow context in pending state
DATA(lo_context) = NEW zcl_workflow_context(
NEW zcl_state_pending( )
).
" Build and fire the approval chain
DATA(lo_chain) = build_approval_chain( ).
DATA(lv_result) = lo_chain->handle(
VALUE zs_approval_request(
id = ls_item-id
amount = ls_item-amount
)
).
" State transitions happen inside the chain handlers
lo_context->process( ).
" Log outcome
WRITE: / ls_item-id, lo_context->get_status( ).
ENDWHILE.
When to Use Each Pattern
I've seen developers reach for Chain of Responsibility when they really need Strategy, and vice versa. Here's how I think about it:
- Chain of Responsibility: one request, multiple potential handlers, exactly one (or zero) handles it. Order matters. Think approval thresholds, validation pipelines, middleware.
- State: one object whose behavior changes dramatically depending on its current phase. Think document lifecycle, order status, ticket resolution flow.
- Iterator: you want to process a collection uniformly without the consumer caring how items are stored internally. Think worklists, batch jobs, paginated results.
These three work together naturally because real workflows involve all three concerns simultaneously: routing decisions (chain), lifecycle tracking (state), and collection processing (iterator).
Testing Your Chain
One major benefit of this structure is testability. Each handler is a small, focused class you can test in isolation. For ABAP unit testing with test doubles, you can inject a mock "next" handler that records whether it was called:
CLASS ltc_team_lead_handler DEFINITION FOR TESTING
RISK LEVEL HARMLESS DURATION SHORT.
PRIVATE SECTION.
DATA mo_cut TYPE REF TO zcl_team_lead_handler.
DATA mo_next TYPE REF TO zcl_mock_handler.
METHODS:
setup,
test_handles_small_amount FOR TESTING,
test_passes_large_amount FOR TESTING.
ENDCLASS.
CLASS ltc_team_lead_handler IMPLEMENTATION.
METHOD setup.
mo_cut = NEW #( ).
mo_next = NEW zcl_mock_handler( ).
mo_cut->set_next( mo_next ).
ENDMETHOD.
METHOD test_handles_small_amount.
DATA(lv_result) = mo_cut->handle(
VALUE #( id = '001' amount = '500.00' )
).
cl_abap_unit_assert=>assert_true(
act = lv_result
msg = 'Team lead should handle amounts under 1000'
).
cl_abap_unit_assert=>assert_false(
act = mo_next->was_called( )
msg = 'Next handler should not be invoked'
).
ENDMETHOD.
METHOD test_passes_large_amount.
mo_cut->handle( VALUE #( id = '002' amount = '5000.00' ) ).
cl_abap_unit_assert=>assert_true(
act = mo_next->was_called( )
msg = 'Should forward to next handler'
).
ENDMETHOD.
ENDCLASS.
Each handler is independently verifiable. Compare that to testing a 300-line IF/ELSEIF approval function module — which usually means you need an actual SAP workflow customizing setup just to run the test.
Practical Considerations
A few things I've learned building these in real SAP projects:
- Don't build infinite chains. If you have more than 5-6 handlers, ask whether some should be combined or configured differently. A table-driven chain (reading handler configuration from a Customizing table) is often better than hardcoded wiring for complex approval matrices.
- Log the chain decision. Each handler should write a structured log entry so you can trace why a document ended up where it did. Auditors ask this question constantly.
- Handle the unhandled case explicitly. If nothing in the chain handles a request, that's a business error — don't silently ignore it. Raise a class-based exception. For more on exception design, see clean exception handling in ABAP.
- State transitions should be atomic. If you're persisting workflow state, do the transition inside a
CALL FUNCTION ... IN UPDATE TASKor within a proper LUW boundary.
Wrapping Up
The ABAP chain of responsibility pattern, combined with State and Iterator, gives you a workflow architecture that's genuinely maintainable. New approval levels? Add one class. New workflow state? Add one state class. Processing a different collection type? Implement a new iterator.
The upfront investment in structuring your code this way pays back within the first business rule change request. And in enterprise SAP projects, those change requests come constantly.
If you want to see how clean code principles support these patterns at a broader level, the ABAP clean code refactoring guide covers the mindset shifts that make OOP patterns stick long-term in SAP teams.