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:
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:
@DslMarkerannotation class BpmnDslAnnotating 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:
/** A scope that can own flow nodes: a process, a lane, or a subprocess. */@BpmnDslsealed 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. */@BpmnDslinterface 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:
@BpmnDslclass 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:
@BpmnDslsealed 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:
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:
@BpmnDslsealed 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)}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:
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:
@BpmnDslclass 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:
@Testfun `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
messageFlowon 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 callbuild()and re-use a builder, mutable builders leak. Keep itinternal.- 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.