In a traditional SAP RAP application, we typically work with CDS views that directly read from database tables. But what happens when you need to display data that doesn't come from a single source, or when you need to call external APIs, BAPIs, or perform complex calculations?
That's where Custom Entities come to the rescue.
A Custom Entity is essentially a "wrapper" around ABAP code that provides data instead of reading directly from database tables. Think of it as a bridge between your complex data retrieval logic and the RAP framework.
In this blog, we'll build a practical Customer app using Custom Entity to understand how it all works together.
What Makes Custom Entity Special?
Unlike regular CDS views that have a SELECT
statement, Custom Entities have no SELECT clause. Instead, they delegate the data retrieval to an ABAP class that implements the IF_RAP_QUERY_PROVIDER
interface. This gives you complete control over how and where your data comes from.
The beauty of Custom Entity lies in its flexibility:
-
Call external web services or APIs
-
Execute BAPIs or function modules
-
Combine data from multiple sources
-
Perform complex calculations
-
Apply custom business logic during data retrieval
The Four Essential Objects
Every Custom Entity RAP app requires at least four objects to create a read only application:
-
Custom Entity CDS - Defines the structure and UI annotations
-
Query Implementation Class - Contains the data retrieval logic
-
Service Definition - Exposes the entity as a service
-
Service Binding - Publishes the service for consumption
Let's build them step by step using our Customer example.
Step 1: Create the Query Implementation Class First
We always start with the class because the Custom Entity CDS validates that the class exists when you try to activate it.
CLASS zsac_cl_ce_customer DEFINITION
PUBLIC
FINAL
CREATE PUBLIC .
PUBLIC SECTION.
INTERFACES if_rap_query_provider.
PROTECTED SECTION.
PRIVATE SECTION.
ENDCLASS.
CLASS zsac_cl_ce_customer IMPLEMENTATION.
METHOD if_rap_query_provider~select.
" Implementation will come here
ENDMETHOD.
ENDCLASS.
The IF_RAP_QUERY_PROVIDER
interface is the heart of Custom Entity functionality. It contains one method SELECT
that gets called whenever the framework needs data.
Step 2: Create the Custom Entity CDS
Now we can create our Custom Entity structure with UI annotations:
@EndUserText.label: 'Custom Entity for Customer'
@ObjectModel.query.implementedBy: 'ABAP:ZSAC_CL_CE_CUSTOMER'
@UI: {
headerInfo: { typeName: 'Customer',
typeNamePlural: 'Customers',
title: { type: #STANDARD, label: 'Customer ID', value: 'CustomerID' },
description: { type: #STANDARD, label: 'Customer Name', value: 'FullName' }
}
}
define root custom entity ZSAC_CE_CUSTOMER
{
@UI.facet : [{
id : 'Customer',
purpose : #STANDARD,
position: 10,
label : 'Customer',
type : #IDENTIFICATION_REFERENCE
}]
@UI : { lineItem: [{ position: 10 }],
identification: [{ position: 10 }],
selectionField: [{ position: 10 }] }
key CustomerID : /dmo/customer_id;
@UI : { lineItem: [{ position: 20 }],
identification: [{ position: 20 }],
selectionField: [{ position: 20 }] }
FirstName : /dmo/first_name;
@UI : { lineItem: [{ position: 30 }],
identification: [{ position: 30 }],
selectionField: [{ position: 30 }] }
LastName : /dmo/last_name;
@UI : { lineItem: [{ position: 40 }],
identification: [{ position: 40 }],
selectionField: [{ position: 40 }] }
Street : /dmo/street;
@UI : { lineItem: [{ position: 50 }],
identification: [{ position: 50 }],
selectionField: [{ position: 50 }] }
PostalCode : /dmo/postal_code;
@UI : { lineItem: [{ position: 60 }],
identification: [{ position: 60 }],
selectionField: [{ position: 60 }] }
City : /dmo/city;
@UI : { lineItem: [{ position: 70 }],
identification: [{ position: 70 }] }
@EndUserText.label: 'Country'
Country : abap.char(50);
@UI : { lineItem: [{ position: 80 }],
identification: [{ position: 80 }] }
PhoneNumber : /dmo/phone_number;
@UI : { lineItem: [{ position: 90 }],
identification: [{ position: 90 }] }
EmailAddress : /dmo/email_address;
@UI : { lineItem: [{ position: 100 }],
identification: [{ position: 100 }] }
@EndUserText.label: 'Customer Name'
FullName : abap.char(255);
}
The key annotation here is @ObjectModel.query.implementedBy: 'ABAP:ZSAC_CL_CE_CUSTOMER'
which tells RAP which class to call for data retrieval.
Step 3: Understanding io_request and io_response
Now comes the crucial part - implementing the SELECT method. The interface provides two important objects:
-
io_request
- Contains information about what the client is asking for -
io_response
- Used to send data back to the clien
What io_request Gives You
The io_request
object is packed with useful information:
" Check if data is requested (vs just count)
IF io_request->is_data_requested( ).
" Get paging information
DATA(lv_top) = io_request->get_paging( )->get_page_size( ).
DATA(lv_skip) = io_request->get_paging( )->get_offset( ).
" Get requested fields (for optimization)
DATA(lt_requested_fields) = io_request->get_requested_elements( ).
" Get sorting requirements
DATA(lt_sort) = io_request->get_sort_elements( ).
" Get filter conditions
DATA(lv_conditions) = io_request->get_filter( )->get_as_sql_string( ).
What io_response Expects
The io_response
object is your way to send data back:
" Set the actual data
io_response->set_data( lt_customer_data ).
" Set total count for paging
io_response->set_total_number_of_records( lv_records ).
Critical Note: You must implement both set_data()
and set_total_number_of_records()
even if you think you don't need the count. The framework requires it and will throw a dump if missing.
Step 4: Complete Query Implementation
Here's our complete implementation with all the logic:
CLASS zsac_cl_ce_customer DEFINITION
PUBLIC
FINAL
CREATE PUBLIC .
PUBLIC SECTION.
INTERFACES if_rap_query_provider.
PROTECTED SECTION.
PRIVATE SECTION.
ENDCLASS.
CLASS zsac_cl_ce_customer IMPLEMENTATION.
METHOD if_rap_query_provider~select.
DATA: lt_customer_data TYPE STANDARD TABLE OF zsac_ce_customer.
IF io_request->is_data_requested( ).
" Get the requested fields
DATA(lt_requested_fields) = io_request->get_requested_elements( ).
DATA(lv_top) = io_request->get_paging( )->get_page_size( ).
IF lv_top <= 0. lv_top = 1. ENDIF.
DATA(lv_skip) = io_request->get_paging( )->get_offset( ).
DATA(lt_sort) = io_request->get_sort_elements( ).
DATA : lv_orderby TYPE string.
LOOP AT lt_sort INTO DATA(ls_sort).
IF ls_sort-descending = abap_true.
lv_orderby = |{ lv_orderby } { ls_sort-element_name } DESCENDING |.
ELSE.
lv_orderby = |{ lv_orderby } { ls_sort-element_name } ASCENDING |.
ENDIF.
ENDLOOP.
IF lv_orderby IS INITIAL.
lv_orderby = 'CUSTOMERID'.
ENDIF.
DATA(lv_conditions) = io_request->get_filter( )->get_as_sql_string( ).
* Total number of records
SELECT COUNT( customer_id ) FROM /dmo/customer INTO @DATA(lv_records).
" Select: no alias for the main view (to keep filter simple), aliases only for joined texts
IF lv_conditions IS INITIAL.
SELECT FROM /DMO/I_Customer
INNER JOIN I_Country AS Country ON /DMO/I_Customer~CountryCode = Country~Country
INNER JOIN I_CountryText AS CText ON Country~Country = CText~Country
AND CText~Language = @sy-langu
FIELDS
/DMO/I_Customer~CustomerID,
/DMO/I_Customer~FirstName,
/DMO/I_Customer~LastName,
concat_with_space( /DMO/I_Customer~FirstName, /DMO/I_Customer~LastName, 1 ) AS FullName,
/DMO/I_Customer~Street,
/DMO/I_Customer~PostalCode,
/DMO/I_Customer~City,
CText~CountryName AS Country,
/DMO/I_Customer~PhoneNumber,
/DMO/I_Customer~EmailAddress
ORDER BY (lv_orderby)
INTO CORRESPONDING FIELDS OF TABLE @lt_customer_data
UP TO @lv_top ROWS OFFSET @lv_skip.
ELSE.
SELECT FROM /DMO/I_Customer
INNER JOIN I_Country AS Country ON /DMO/I_Customer~CountryCode = Country~Country
INNER JOIN I_CountryText AS CText ON Country~Country = CText~Country
AND CText~Language = @sy-langu
FIELDS
/DMO/I_Customer~CustomerID,
/DMO/I_Customer~FirstName,
/DMO/I_Customer~LastName,
concat_with_space( /DMO/I_Customer~FirstName, /DMO/I_Customer~LastName, 1 ) AS FullName,
/DMO/I_Customer~Street,
/DMO/I_Customer~PostalCode,
/DMO/I_Customer~City,
CText~CountryName AS Country,
/DMO/I_Customer~PhoneNumber,
/DMO/I_Customer~EmailAddress
WHERE (lv_conditions) " only when not initial
ORDER BY (lv_orderby)
INTO CORRESPONDING FIELDS OF TABLE @lt_customer_data
UP TO @lv_top ROWS OFFSET @lv_skip.
ENDIF.
io_response->set_total_number_of_records( lv_records ).
io_response->set_data( lt_customer_data ).
ENDIF.
ENDMETHOD.
ENDCLASS.
Why the Complex JOIN Logic?
You might wonder why we're doing all these JOINs when we could simply read from the CDS view using path expressions. Here's the key insight:
When you need language-specific text (like Country names), you have three options:
-
Create a CDS view that exposes the text
-
Use path expressions in yourSQL query.
-
Join in Open SQL like we do here and filter
ctext~language = sy-langu
We are joining I_Country
and I_CountryText
to get the country name in the user's login language.
Common Pitfalls and How to Fix Them
1. Boolean Expression Expected
Problem: Using WHERE (lv_conditions)
when lv_conditions
is initial
Solution: Always check if conditions exist before using them:
IF lv_conditions IS INITIAL.
" SELECT without WHERE clause
ELSE.
" SELECT with WHERE (lv_conditions)
ENDIF.
2. Ambiguous Field Names
Problem: Filter fails because field names are ambiguous in JOINs
Solution: Keep both technical field (for filters) and display field (for UI) when needed. Use proper aliases in your SELECT.
3. Duplicate Resource Error
Problem: RAP expects unique keys, but your JOINs cause duplicates
Solution: Ensure CustomerID is unique in returned rows or use DISTINCT/grouping when your joins cause duplication.
4. Dynamic ORDER BY Issues
Problem: lv_orderby
contains invalid column names
Solution: Make sure lv_orderby
contains valid column names that match your SELECT list, without stray quotes or braces.
5. Requested Fields Optimization
Problem: Always selecting all fields impacts performance
Solution: Use io_request->get_requested_elements( )
to select only needed columns and optimize your JOINs accordingly.
Step 5: Create Service Definition
Right-click on your Custom Entity and create a Service Definition:
@EndUserText.label: 'Customer Service Definition'
define service ZUI_SAC_CUSTOMER {
expose ZSAC_CE_CUSTOMER as Customer;
}
The Service Definition controls what gets exposed to the outside world.
Step 6: Create Service Binding
Right-click on the Service Definition and create a Service Binding:
-
Name:
ZUI_SAC_CUSTOMER_O4
-
Binding Type:
OData V4 - UI
(for Fiori Elements apps) -
Service Definition: Your service definition name
After activation, click Publish to make the service available. Then you can click Preview to test your Custom Entity app.
Performance Considerations
Custom Entities require careful performance planning since you're implementing the query logic manually:
-
Implement paging properly - Always respect
get_page_size()
andget_offset()
-
Handle filtering efficiently - Use indexed fields where possible
-
Consider caching - For relatively static data, implement caching mechanisms
-
Optimize JOINs - Only join tables you actually need for the requested fields
Testing Your Custom Entity
Unlike regular CDS views, you cannot test Custom Entities using ADT Data Preview since there's no underlying database artifact. Instead:
-
Use the Service Binding preview - This is your primary testing method
-
Test in Fiori Elements - The most realistic test environment
When to Use Custom Entity vs Regular CDS
Use Custom Entity when:
-
Data comes from external sources (APIs, BAPIs)
-
Complex calculations are needed
-
Multiple data sources must be combined
-
You need full control over data retrieval logic
Use Regular CDS when:
-
Data comes from database tables
-
Standard joins and calculations suffice
-
You want automatic optimization by the database
Here's a challenge for you:
Try filtering by the Country field in your app. You might encounter a short dump error that prevents the filter from working properly.
Your Task:
- Identify and analyze why the Country filter fails while other filters work fine
- Implement a solution that makes Country filtering work seamlessly
Wrapping Up
Custom Entities in SAP RAP provide incredible flexibility for scenarios where standard CDS views fall short.
By understanding how io_request
and io_response
work together, properly taking care of data retrieval logic, and avoiding common pitfalls, you can build robust apps that handle complex data scenarios.
The key takeaways:
-
Always implement both data and count methods in
io_response
-
Handle filter conditions carefully to avoid Boolean expression errors
-
Use proper aliases to prevent ambiguous column names
-
Consider performance implications of your query logic
-
Test through Service Binding preview since ADT Data Preview isn't available
One suggestion by my side - Debug the service and check how io_request
and io_response
are populated.
Thanks for reading :)