Explore the declarative power of status-transition flows
Learn what CAP's status-transition flows feature is, and how to use it.
Overview
You will learn
- What the status-transition flows feature is
- Where it can be useful
- What facilities it provides
- How you can use it
Prerequisites
Prerequisites
/workspaces/cap-nodejs-dev-env. If you have your own CAP Node.js development environment setup, then please make the appropriate adjustments where necessary.Steps
Intro
Released towards the end of 2025, status-transition flows moves us one step closer to declarative nirvana, and in the right direction with regards to LLM-based learning about CAP powered solutions, a smaller code surface area, and a shift left of logic and definitions.
In this tutorial we’ll explore the feature with a simple model with different states and restrictions on transitions between them.
๐ First, initialize a new CAP project, specifying that it will be a Node.js based one, and then open it up in a new IDE session:
cds init --add nodejs cap-status-transition-flows๐ Now open the new cap-status-transition-flows/ directory in a new VS Code / Codespace window:
code cap-status-transition-flows/This should place you and any new terminal session in the new cap-status-transition-flows/ directory.
๐ Following the mantra of “the simplest thing that could possibly work”, create a new services.cds file with the following contents:
context narrowboat {
type Position : String enum {
Forward;
Neutral;
Reverse;
}
entity Controls {
key ID : Integer;
position : Position default #Neutral;
}
}
service Morse {
entity Controls as projection on narrowboat.Controls
actions {
action engageForward();
action engageNeutral();
action engageReverse();
};
}If you wish, you can also remove extraneous project files to keep things clean and to a minimum to avoid distractions:
Shellrm -rf app/ db/ srv/ readme.md
This CDS model defines a simple service Morse that has a pass-through projection (an inferred signature) to a Controls entity, which has a position element that represents the control status at any given time. The projection defines three bound actions (engage...) each of which are for moving the control to a specific position.
Moving from the model to the physical world, the Controls entity represents a Morse control on a narrowboat, which has three positions, selectable by moving a lever. That lever controls the gearbox (and the throttle) and is how one selects forward gear, neutral, or reverse gear. With such controls, to get from forward to reverse, or vice versa, one must first go via neutral, so as not to damage the gearbox.
In other words:
- a control should start out in the neutral position
- can only be moved to forward, or reverse, from that neutral position
- can not be moved directly from forward to reverse, or from reverse to forward
This control state switching and limitation is what we will end up achieving, with help from the status-transition flow feature.
In this tutorial we’ll be testing the control state switching and limitations with unit tests, so we can cleanly describe them, run and re-run them as appropriate. We’ll use the cds test based harness which comes as a separate package @cds-js/cds-test.
๐ Install that package as a development dependency:
npm add @cap-js/cds-testLet’s add some tests to check the basics of what we have defined, and also of what we expect, with regards to control state limitations.
๐ In a new test/ directory, create a file Basics.test.js with the following content:
import cds from '@sap/cds'
const { GET, POST, expect, defaults } = cds.test(import.meta.dirname + '/..')
defaults.path = '/odata/v4/morse'
describe('Basics', () => {
it('allows the creation of new controls', async () => {
const { status } = await POST('Controls', { ID: 1 })
expect(status).to.equal(201)
})
it('gives new controls a Neutral default position', async () => {
const { data } = await POST('Controls', { ID: 2 })
expect(data.position).to.equal('Neutral')
})
it('prevents positions being specified on creation', async () => {
const { data } = await POST('Controls', { ID: 3, position: "Reverse" })
expect(data.position).to.equal('Neutral')
})
})There are three tests here, that check:
- the general creation of new controls
- that controls get a default position of Neutral
- that we cannot override that default and specify Forward or Reverse as the initial position for a new control
๐ Now put these tests to work:
cds testYou should see output that looks something like this:
Basics
โ allows the creation of new controls
โ gives new controls a Neutral default position
X prevents positions being specified on creation
Error [AssertionError]: expected 'Reverse' to equal 'Neutral'
at TestContext.<anonymous> (file:///workspaces/cap-nodejs-dev-env/cap-status-transition-flows/test/Basics.test.js:20:30)
actual: 'Reverse',
expected: 'Neutral',
showDiff: true,
operator: 'strictEqual'
}
2 passed
1 failed
0.680sOK, so our first two tests pass, but we’re not prevented from creating new controls with a non-Neutral position.
Let’s hold that thought.
Moving from creation to use of control instances, let’s add another batch of tests relating to transition. We’ll start with a single test.
๐ Create another new file Transitions.test.js, also in the test/ directory, with the following content:
import cds from '@sap/cds'
const { GET, POST, expect, defaults } = cds.test(import.meta.dirname + '/..')
defaults.path = '/odata/v4/morse'
describe('Transitions', () => {
it('allows moving from Neutral to Forward', async () => {
const { data } = await POST('Controls', { ID: 1 })
const { status } = await POST(`Controls/1/engageForward`)
expect(status).to.equal(204)
})
})Invoke this test with cds test Transitions, whereupon you should see something similar to this:
Transitions
X allows moving from Neutral to Forward
Error: 501 - Service "Morse" has no handler for "engageForward Morse.Controls".
at async TestContext.<anonymous> (file:///workspaces/cap-nodejs-dev-env/cap-status-transition-flows/test/Transitions.test.js:10:22)
response: {
data: {
error: {
message: 'Service "Morse" has no handler for "engageForward Morse.Controls".',
code: '501',
'@Common.numericSeverity': 4
}
}
},
status: 501,
code: '501',
'@Common.numericSeverity': 4
}
1 failed
4.780sIf we take a step back we can see that:
- we can create new control instances
- new control instances by default have the Neutral position
- but we’re not prevented from creating instances with other positions
- there are no implementations for the bound actions such as
engageForward
The status-transition flow feature can bring about what we want, and more. Even better, we can express our requirements purely declaratively, in the form of annotations.
๐ To the end of services.cds, add this:
annotate Morse.Controls with @flow.status: position;
annotate Morse.Controls actions {
engageForward @from: #Neutral @to: #Forward;
engageNeutral @from: [
#Forward,
#Reverse
] @to: #Neutral;
engageReverse @from: #Neutral @to: #Reverse;
};Often, we will find such annotations made together, like this (which is equivalent):
CDSannotate Morse.Controls with @flow.status: position actions { engageForward @from: #Neutral @to: #Forward; engageNeutral @from: [ #Forward, #Reverse ] @to: #Neutral; engageReverse @from: #Neutral @to: #Reverse; };However, for the purposes of learning and clarity, the annotations are made separately:
- on the flow status element
- on the bound actions
The annotation detail here identifies the position element of the Controls entity as the element for which to establish a status-transition flow (i.e. the element that will represent the current status).
The element so identified will also receive the @readonly annotation to prevent unwanted external influence.
๐ Use the “Preview as YAML” feature of the CDS Language Support extension (indicated by the red box) to see the compiled (CSN) version of the model in services.cds, like this:

Alternatively, just use
cds compile --to yaml services.cdson the command line. You can even narrow the output down to what we’re looking for, like this:Shellcds compile --to yaml services.cds \ | yq -y '.definitions["Morse.Controls"].elements.positionwhich should show something like this:
YAMLtype: narrowboat.Position default: '#': Neutral val: Neutral '@flow.status': true '@readonly': true
๐ Note the @readonly annotation on the position element.
๐ Look also at the three bound actions engageForward, engageNeutral and engageReverse, which have also been annotated. Each has a pair of @from and @to annotations, describing the transition status limitations, reflecting the requirements of our Morse control model. For example, both engageForward and engageReverse are only “valid” when starting from a Neutral position.
We have added no code, only these annotations. Let’s see the effect on our tests.
๐ Rerun all the tests we have so far, with cds test (this will run tests in all files that it finds, which will include test/Basics.test.js and test/Transitions.test.js).
You should see output similar to this:
Running 2 test suites...
โ test/Transitions.test.js
โ test/Basics.test.js
4 in 2 suites passed
0.946sThe annotations alone have done the heavy lifting, not least providing automatic implementations for the bound actions that will perform the transitions as appropriate.
To really get the feel for what the status-transition flow feature brings, let’s add some more tests.
๐ Add four more tests to the “Transitions” test bundle, so that the test/Transitions.test.js file looks like this:
import cds from '@sap/cds'
const { GET, POST, expect, defaults } = cds.test(import.meta.dirname + '/..')
defaults.path = '/odata/v4/morse'
describe('Transitions', () => {
it('allows moving from Neutral to Forward', async () => {
const { data } = await POST('Controls', { ID: 1 })
const { status } = await POST(`Controls/1/engageForward`)
expect(status).to.equal(204)
})
it('tracks the position after engagement', async () => {
const { data } = await GET('Controls/1')
expect(data.position).to.equal('Forward')
})
it('prevents moving from Forward directly to Reverse', async () => {
const { data } = await POST(
'Controls/1/engageReverse',
null,
{ validateStatus: status => status == 409 }
)
expect(data.error.code).to.equal('INVALID_FLOW_TRANSITION_SINGLE')
})
it('allows moving from Forward to Neutral', async () => {
const { status } = await POST('Controls/1/engageNeutral')
expect(status).to.equal(204)
})
it('allows moving from Neutral to Reverse', async () => {
const { status } = await POST('Controls/1/engageReverse')
expect(status).to.equal(204)
})
})Together, this set of tests:
- creates a new control instance
- invokes
engageForwardon it - checks the position is then set to Forward
- checks that we can’t then move that control from Forward direct to Reverse
- checks that we can move it to Neutral, and then to Reverse
๐ Run all the tests like before, with cds test.
The output should also show success on all test counts:
Running 2 test suites...
โ test/Basics.test.js
โ test/Transitions.test.js
8 in 2 suites passed
0.906sSuccess!
For further info, refer to these resources:
- An interview with Ward Cunningham, who popularized the phrase “the simplest thing that could possibly work”
- Blog post on using services.cds in simple CDS model examples
- An overview of Morse lever controls on The Fitout Pontoon’s website
- Blog post A simple exploration of status-transition flows
- The Capire topic Testing with cds.test covers
cds testand a whole lot more
Resources
Discussion
Share feedback on this tutorial or join the conversation in SAP Community.