When your application built with the SAP Cloud Programming Model starts growing, you’ll notice that certain behavior should be shared across your CAP services. You want to have a single source of truth for your core business logic. This happened to us in a development project at SAP when offering multiple Fiori applications for different personas, and at the same time providing a public API. We decided to establish a layer of domain services to solve this problem. Let me explain you how that works.
Distinguishing Consumption Services from Domain services
The core idea is to organize your application-level CAP services in two layers: An outer layer containing “consumption services” and an inner layer containing “domain services”. Domain services implement core business logic of a certain domain. Domain services are supposed to be agnostic to the consumption channel from where they are used. In contrast to the domain services, consumption services are built for a certain channel, such as user interfaces, public APIs or asynchronous messaging. They implement the specifics for a certain channel, for example defaulting values in a user interface, and delegate core business logic to domain services. That concept is inspired by hexagonal architecture where an application core (domain services) is surrounded by adapters (consumption services).
The following picture illustrates that visually: The green consumption services implement their channel-specific logic in event handlers, and delegate to a domain service that implements the core domain behavior in its own event handlers that are transparent to the consumption services.
Both types of services are modelled in CDS such that consumption services use the model of the domain services. Domain service delegation at runtime is not provided automatically, it needs to be implemented in a delegation handler within the consumption services. That delegation handler replaces the default ON event handler and delegates to the domain service. If that is omitted, the domain service event handlers are not called.
Practical Domain Service Example
Let me walk you through a simplistic bookshop example to illustrate the concept and start with the underlying data model: The usual CAP book example, enhanced with a summary and a managed aspect.
namespace com.sap.samples;
using {
managed
} from '@sap/cds/common';
entity Book : managed {
key ID : UUID;
title : String;
summary : String;
}
A simple domain service on top of that model could look like this:
using com.sap.samples as samples from '../db/data-model';
@protocol: 'none' // Domain services are internal and not exposed
service BookDomainService {
entity Book as projection on samples.Book;
}
Please observe the annotation @protocol: ‘none’ that will take care of that the domain service is not exposed externally.
In that example, two consumption services are provided: one for building a book maintenance admin user interface and another one for a book import API. Let’s start with the UI service:
using BookDomainService as ds from './book-domain-service';
@requires: 'BookAdmin'
service BookAdminUIService {
entity Book as projection on ds.Book excluding {
createdAt, createdBy, modifiedAt, modifiedBy
};
}
The most important difference is that we define the Book entity based on the entity definition of the domain service. In this case we have decided not to expose certain attributes from the domain service. In addition, the UI service requires the user to be authenticated and to have a static role “BookAdmin”.
This is how the API service is modelled using the entity from the domain service:
using BookDomainService as ds from './book-domain-service';
@requires: 'system-user'
service BookAPIService {
entity Book as projection on ds.Book {
ID,
title,
summary as description
};
}
The summary attribute was earlier called “description”. To be compatible to previous API users, we keep that name. The API is protected by the pseudo-role “system-user” for technical access.
The last piece that is missing is the runtime delegation from consumption services to the domain service. In CAP Java, this can be accomplished by an event handler implementation like this:
@Component
@ServiceName({BookAdminUIService_.CDS_NAME, BookAPIService_.CDS_NAME})
public class BookDomainServiceDelegatorHandler implements EventHandler {
private final ApplicationService ds;
BookDomainServiceDelegatorHandler(
@Qualifier(BookDomainService_.CDS_NAME) ApplicationService ds) {
this.ds = ds;
}
@On
Result delegateRead(CdsReadEventContext context) {
return ds.run(context.getCqn(), context.getCqnNamedValues());
}
@On
Result delegateCreate(CdsCreateEventContext context) {
return ds.run(context.getCqn());
}
@On
Result delegateUpsert(CdsUpsertEventContext context) {
return ds.run(context.getCqn());
}
@On
Result delegateUpdate(CdsUpdateEventContext context) {
return ds.run(context.getCqn(), context.getCqnValueSets());
}
@On
Result delegateDelete(CdsDeleteEventContext context) {
return ds.run(context.getCqn(), context.getCqnValueSets());
}
}
The domain service is injected, and all CRUDQ events from the consumption services are delegated to it. What about the different attribute names in consumption and domain services, e.g. renaming of “summary” to “description”? There is good news: That is automatically done by CAP under the hood! The CAP team has documented which kind of projections are resolvable.
Any event handler registered on the domain service will always run, independent whether the API or the UI service is being used, e.g. this handler that will normalize all book titles to upper case will always run.
@Component
@ServiceName(BookDomainService_.CDS_NAME)
public class BookTitleNormalizationHandler implements EventHandler {
@Before(event = { CqnService.EVENT_CREATE, CqnService.EVENT_UPDATE, CqnService.EVENT_UPSERT })
public void normalizeTitle(List<Book> books) {
books.forEach(book -> {
if (book.containsKey(Book.TITLE) && book.getTitle() != null) {
book.setTitle(book.getTitle().toUpperCase());
}
});
}
}
Some Words on Actions, Functions and Fiori Drafts
So, we are all set, and I could finish this blog post? Well, there are some things to be aware of:
Unfortunately, action and function definitions cannot be imported in CDS, such that we need to redefine the actions in consumption services. We can of course import types that are used to define the signature of a complex action or function. Consequently, also delegation of actions and functions needs to be taken care of on your own in the delegation code.
Fiori drafts are an ideal candidate to illustrate the benefits of domain services: Annotating a simple entity with @odata.draft.enabled turns this into a complex construct. A draft-enabled entity has two different persisted states, a compound key with an additional element “IsActiveEntity” and a complex draft process orchestration. In other words: Nothing that you want to impose on external API consumers and nothing where you want your core domain logic to depend on. These complexities need additional treatment when delegating to domain services: Update and delete statements entity references contain the additional draft key “IsActiveEntity”. This needs to be removed, as the domain service entities does not know that key. This can be done by the CAP Query Modification API. Let me know if you’re interested, then I will share some code snippets how this could be done.
As of today, reading of drafts and active entities is highly interwoven, this means that read event handlers cannot be delegated to domain services.
Positive Results in Practice
So far, my practical experience with establishing a domain service layer is positive. It helps to keep a single, consistent source truth for behavior and business logic across several different consumption channels. The mentioned implementation limitations are unfortunate, but the overall value is still there.
What do you think about this approach and which solutions did you find? Any questions or proposals for improvement? Let me know in the comments below or on other channels!
For being notified about my future blogs, please subscribe to Ulrich Bestfleisch.
Related Resources
- SAP Cloud Application Programming Model Topic Page
- Further Blogs on SAP Cloud Application Programming Model
- Q&A SAP Cloud Application Programming Model