Series overview
Part 3 of 1030% complete
2026-09-15•5 min read

Part 3: Designing a type-safe Kotlin DSL for BPMN

The AST from Part 2 is correct but unusable as an authoring surface — nobody wants to nest ProcessDefinition(nodes = listOf(UserTask(...))) by hand. This part builds the fluent layer: bpmnCollaboration("enterpriseProcurement") { participant(...) { process(...) { lane(...) { ... } } } }. All of it compiles to the same immutable CollaborationDefinition.

Lambdas with receivers — the whole trick

The mechanism is one Kotlin feature: a function type T.() -> Unit where the lambda runs with T as its receiver, so this inside the block is the builder:

dsl/Dsl.kt
fun bpmnCollaboration(id: String, block: CollaborationBuilder.() -> Unit): CollaborationDefinition =
CollaborationBuilder(id).apply(block).build()

Three lines, three responsibilities: create the mutable builder, run the author’s block against it, freeze it into the immutable AST. build() is internal — the DSL’s public surface is the block, not the builder.

@DslMarker: preventing scope leakage

Without a marker, every receiver in the lexical nesting is visible inside every block. Inside lane { }, the author could call participant { } — which resolves against the enclosing CollaborationBuilder and silently adds a participant while the author thought they were in a lane:

@DslMarker
annotation class BpmnDsl

Annotating every builder class with @BpmnDsl tells the compiler: inside a lambda whose receiver is @BpmnDsl-annotated, only the innermost @BpmnDsl receiver’s members resolve implicitly. Calling messageFlow(...) inside lane { } is now a compile error — the collaboration builder’s members are no longer in scope. This is the difference between a DSL that reads well in examples and one that doesn’t corrupt real models when a block is mis-nested.

Two scope interfaces, not one builder hierarchy

Two different “can contain things” relationships exist in BPMN, and they should not merge:

  • Node scope — a process, a lane, or a subprocess can own flow nodes.
  • Flow scope — a process or a subprocess can own sequence flows. A lane cannot.

If lane { } offered sequenceFlow(...), the DSL would be teaching a BPMN falsehood (sequence flows belong to the process; lanes only group nodes). So the capabilities are two separate supertypes:

dsl/Dsl.kt
/** A scope that can own flow nodes: a process, a lane, or a subprocess. */
@BpmnDsl
sealed class FlowNodeContainer {
internal abstract fun register(node: FlowNode)
fun startEvent(id: String, block: StartEventBuilder.() -> Unit = {}) =
register(StartEventBuilder(id).apply(block).build())
fun userTask(id: String, block: UserTaskBuilder.() -> Unit = {}) =
register(UserTaskBuilder(id, this).apply(block).build())
fun serviceTask(id: String, block: ServiceTaskBuilder.() -> Unit = {}) =
register(ServiceTaskBuilder(id, this).apply(block).build())
fun receiveTask(id: String, block: ReceiveTaskBuilder.() -> Unit = {}) =
register(ReceiveTaskBuilder(id, this).apply(block).build())
fun sendTask(id: String, block: SendTaskBuilder.() -> Unit = {}) =
register(SendTaskBuilder(id, this).apply(block).build())
fun exclusiveGateway(id: String, block: ExclusiveGatewayBuilder.() -> Unit = {}) =
register(ExclusiveGatewayBuilder(id).apply(block).build())
fun parallelGateway(id: String, block: ParallelGatewayBuilder.() -> Unit = {}) =
register(ParallelGatewayBuilder(id).apply(block).build())
fun inclusiveGateway(id: String, block: InclusiveGatewayBuilder.() -> Unit = {}) =
register(InclusiveGatewayBuilder(id).apply(block).build())
fun eventBasedGateway(id: String, block: EventBasedGatewayBuilder.() -> Unit = {}) =
register(EventBasedGatewayBuilder(id).apply(block).build())
fun intermediateCatchMessageEvent(id: String, block: CatchMessageEventBuilder.() -> Unit = {}) =
register(CatchMessageEventBuilder(id).apply(block).build())
fun embeddedSubprocess(id: String, block: SubprocessBuilder.() -> Unit = {}) =
register(SubprocessBuilder(id, this).apply(block).build())
}
/** A scope that can own sequence flows: a process or a subprocess, never a lane. */
@BpmnDsl
interface SequenceFlowContainer {
fun registerFlow(flow: SequenceFlow)
fun sequenceFlow(id: String, from: String, to: String, name: String? = null) =
registerFlow(SequenceFlow(id = id, name = name, sourceRef = from, targetRef = to))
fun conditionalFlow(id: String, from: String, to: String, condition: String, name: String? = null) =
registerFlow(
SequenceFlow(
id = id, name = name, sourceRef = from, targetRef = to,
condition = Expression(condition),
),
)
}

sequenceFlow and conditionalFlow are deliberately separate functions rather than an optional condition parameter — a conditional edge is a different authoring act, and keeping them distinct makes the call site self-documenting.

The lane trick: declaring nodes inside a lane block

BPMN XML puts nodes in the process and references them from lanes via flowNodeRef. The pleasant DSL ordering is the reverse: declare the node inside the lane block and let membership be derived:

dsl/Dsl.kt
@BpmnDsl
class LaneBuilder internal constructor(
private val id: String,
private val nodeScope: FlowNodeContainer,
) : FlowNodeContainer() {
var name: String? = null
private val refs = mutableListOf<String>()
/** Nodes declared inside a lane block register in the enclosing scope. */
override fun register(node: FlowNode) {
nodeScope.register(node) // the node belongs to the process…
refs += node.id // …and the lane only records the reference
}
/** Membership by reference, for nodes declared outside the lane block. */
fun contains(vararg nodeIds: String) {
refs += nodeIds
}
internal fun build() = LaneDefinition(
id = id, name = name,
flowNodeRefs = refs.toList(),
)
}

This is the delegation pattern that makes the AST honest: the lane’s block looks like it owns nodes, but register forwards each node to the enclosing process scope and only keeps the id. What you write matches what a lane means — a rendering partition, not a container.

Boundary events: nested syntax, flat registration

The DSL wants boundaryTimer declared inside the activity it guards — that reads correctly and mirrors the diagram. But the AST stores the boundary event as a sibling flow node in the same scope (with attachedToRef pointing at the host). The bridge: every activity builder receives the enclosing FlowNodeContainer and boundary functions append into it:

dsl/Dsl.kt
@BpmnDsl
sealed class ActivityBuilder(private val nodeId: String, private val scope: FlowNodeContainer) :
ElementBuilder() {
fun boundaryTimer(id: String, block: BoundaryTimerBuilder.() -> Unit) {
scope.register(BoundaryTimerBuilder(id, nodeId).apply(block).build())
}
fun boundaryMessage(id: String, message: String, block: BoundaryEventBuilder.() -> Unit = {}) {
scope.register(BoundaryEventBuilder(id, nodeId).apply(block).buildMessage(message))
}
fun boundaryError(id: String, errorRef: String? = null, block: BoundaryEventBuilder.() -> Unit = {}) {
scope.register(BoundaryEventBuilder(id, nodeId).apply(block).buildError(errorRef))
}
}

So this declaration:

example/Procurement.kt (excerpt)
receiveTask("waitForQuotation") {
name = "Wait for Supplier Quotation"
messageRef = "supplierQuotationReceived"
boundaryTimer("quotationSlaExceeded") {
name = "72h supplier SLA"
duration = "PT72H"
cancelActivity = true // interrupting: cancel the wait on SLA breach
}
}

produces a ReceiveTask and a BoundaryTimerEvent(attachedToRef = "waitForQuotation") in the process’s flat node list — exactly the shape BPMN serializes as a <bpmn:boundaryEvent> sibling element.

Because boundaryTimer only exists on ActivityBuilder (which only task/subprocess builders extend), intermediateCatchMessageEvent("x") { boundaryTimer("y") {} } is a compile error. Part 4 shows why this matters: the intuitive sketch of this example attaches a timer to a catch event, which is invalid BPMN — boundary events attach to activities.

Element and gateway builders

One shared base for id/name/documentation/extensions; specific subclasses for what each element type may express:

dsl/Dsl.kt
@BpmnDsl
sealed class ElementBuilder {
var name: String? = null
var documentation: String? = null
private val extensions = mutableListOf<ExtensionElement>()
fun extension(prefix: String? = null, name: String, vararg attributes: Pair<String, String>) {
extensions += ExtensionElement(prefix = prefix, name = name, attributes = attributes.toMap())
}
internal fun collectExtensions(): List<ExtensionElement> = extensions.toList()
internal fun doc(): Documentation? = documentation?.let(::Documentation)
}
dsl/Dsl.kt
class ServiceTaskBuilder internal constructor(private val id: String, scope: FlowNodeContainer) :
ActivityBuilder(id, scope) {
/** Logical delegate name; the engine adapter decides how to serialize it. */
var delegate: String? = null
var delegateClass: String? = null
var expression: String? = null
internal fun build() = ServiceTask(
id, name, doc(),
metadata = buildMap {
delegate?.let { put("delegate", it) }
delegateClass?.let { put("class", it) }
expression?.let { put("expression", it) }
},
extensions = collectExtensions(),
)
}
class ExclusiveGatewayBuilder internal constructor(private val id: String) : ElementBuilder() {
/** Id of the sequence flow taken when every condition evaluates false. */
var defaultFlow: String? = null
internal fun build() = ExclusiveGateway(id, name, doc(), defaultFlow, collectExtensions())
}

delegate = "requisitionValidationDelegate" lands in the metadata map — not a BPMN attribute — and Part 9’s Flowable adapter turns it into flowable:delegateExpression. Same for formKey, assignee, candidateGroup on UserTaskBuilder.

TimerDefinition itself enforces exclusivity at construction:

model/Model.kt
data class TimerDefinition(
val duration: String? = null,
val cycle: String? = null,
val date: String? = null,
) {
init {
require(listOfNotNull(duration, cycle, date).size == 1) {
"TimerDefinition requires exactly one of duration, cycle, or date"
}
}
}

Setting both duration and date throws at DSL evaluation time — before the AST even exists.

The container builders

CollaborationBuilder and ParticipantBuilder are flat collectors; participant { } produces two AST objects — the ParticipantDefinition and the ProcessDefinition it references:

dsl/Dsl.kt
@BpmnDsl
class ParticipantBuilder internal constructor(private val id: String) {
var name: String? = null
var executable: Boolean = false
var documentation: String? = null
private var process: ProcessDefinition? = null
fun process(id: String, block: ProcessBuilder.() -> Unit) {
check(process == null) { "participant '$id' already declares a process" }
process = ProcessBuilder(id).apply(block).build(isExecutable = executable)
}
internal fun build(): Pair<ParticipantDefinition, ProcessDefinition?> =
ParticipantDefinition(id = id, name = name,
documentation = documentation?.let(::Documentation),
processRef = process?.id) to process
}

Note the direction of the executable flag: participant { executable = true } is what the author writes, but isExecutable is a property of the process element in BPMN XML — the builder translates authoring intent into the correct placement.

Mutable builders, immutable product

The asymmetry is the design. Builders are unashamedly mutable (var name, mutableListOf) because authoring order is imperative — you declare things in sequence. The AST is rigidly immutable (val, List, data classes) because everything downstream — validation, layout, serialization — must be deterministic and shareable. The boundary between them is each builder’s build(), called exactly once when the lambda returns.

Verification

kotlinc is the first test. Beyond that, one test proves the plumbing does what the syntax claims:

src/test/kotlin/.../BpmnDslTest.kt (excerpt)
@Test
fun `dsl compiles to an immutable ast`() {
val c = bpmnCollaboration("tiny") {
participant("pool") {
executable = true
process("proc") {
lane("work") {
startEvent("start")
serviceTask("doWork") { delegate = "worker" }
endEvent("done")
}
sequenceFlow("f1", "start", "doWork")
sequenceFlow("f2", "doWork", "done")
}
}
}
val proc = c.processes.single()
assertEquals(3, proc.nodes.size) // lane-owned nodes landed in the process
assertTrue(proc.nodes[0] is StartEvent)
assertEquals("worker", (proc.nodes[1] as ServiceTask).metadata["delegate"])
}

Common pitfalls

  • Forgetting @DslMarker, then discovering scope collisions in review: documentation = "..." inside a nested block mutates the inner receiver when you meant the outer — with a marker, the outer receiver is unreachable.
  • Exposing the same function on every builder. A messageFlow on a lane scope would silently accept cross-pool edges; restrict functions to the scopes where the construct is legal.
  • build() in the public API. Once authors can call build() and re-use a builder, mutable builders leak. Keep it internal.
  • Optional parameters for semantic distinctions. sequenceFlow(id, from, to, condition = null) invites accidental conditional flows; separate names keep call sites honest.

Reference implementation

The full builder layer is Dsl.kt in the bpmn-dsl module of pcnixsys/bpmn-dsl-kotlin — the @BpmnDsl marker, the FlowNodeContainer/SequenceFlowContainer scope split, BoundaryAttachable, and every builder this chapter introduced.

Next

Part 4 uses this DSL to write the entire procurement collaboration end-to-end — and hits the chapter’s real BPMN problem: how to model “budget approval sometimes required, legal review sometimes required” without building a parallel gateway that deadlocks.

KotlinBPMN

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind