Part 10: Turning the DSL into a maintainable workflow authoring library
The last nine parts built a working pipeline. This one answers the question that matters for a real library: how to package it so the next ten BPMN elements slot in without breaking the API, and what was deliberately left out.
Recommended module layout
The seams in the code already suggest Gradle modules — each one is independently testable and independently replaceable:
rootProject.name = "bpmn-dsl"include("bpmn-model", "bpmn-dsl", "bpmn-validation", "bpmn-xml", "bpmn-layout", "bpmn-flowable", "bpmn-example")| Module | Contents | Depends on |
|---|---|---|
bpmn-model | AST: CollaborationDefinition, FlowNode hierarchy, DiagramLayout types | nothing |
bpmn-dsl | bpmnCollaboration, @BpmnDsl builders | bpmn-model |
bpmn-validation | BpmnValidator, Diagnostic | bpmn-model |
bpmn-layout | DiagramLayoutEngine, LayeredLayoutEngine | bpmn-model |
bpmn-xml | BpmnXmlWriter, EngineExtensionSerializer, BpmnNs | bpmn-model (+ bpmn-layout types) |
bpmn-flowable | FlowableExtensionSerializer | bpmn-xml |
bpmn-example | enterpriseProcurementCollaboration(), Main.kt | all |
Dependencies that actually matter: the JDK’s javax.xml.stream (no external dep for XML), kotlin("test") + JUnit 5 for tests, flowable-spring-boot-starter-process only in the Part 9 deployment demo. The library proper needs no third-party runtime dependency.
plugins { kotlin("jvm") version "2.3.21" application}
repositories { mavenCentral() }
kotlin { jvmToolchain(21) }
dependencies { testImplementation(kotlin("test")) testImplementation("org.junit.jupiter:junit-jupiter:5.12.2") testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.12.2")}
application { mainClass.set("in.o612.eng.bpmn.MainKt") }tasks.test { useJUnitPlatform() }The assembled pipeline
Main.kt is the series’ deliverable in miniature — build, validate, lay out, write:
fun main() { val collaboration = enterpriseProcurementCollaboration()
val validator = BpmnValidator() val diagnostics = validator.validate(collaboration) diagnostics.forEach { println("[${it.severity}] ${it.code} ${it.elementId ?: "-"}: ${it.message}") } check(diagnostics.none { it.severity == Severity.ERROR }) { "Validation failed — see diagnostics above" }
val layout = LayeredLayoutEngine().layout(collaboration) val diDiags = validator.validateDiagram(collaboration, layout) check(diDiags.none { it.severity == Severity.ERROR })
val xml = BpmnXmlWriter(FlowableExtensionSerializer).writeToString(collaboration, layout) File("build/procurement.bpmn").apply { parentFile.mkdirs(); writeText(xml) }}Validation gates generation: no XML is written unless both semantic and diagram validation pass. The series’ full source is the Model.kt, Dsl.kt, Validation.kt, BpmnXmlWriter.kt, Layout.kt, Flowable.kt, Procurement.kt, Main.kt, and BpmnDslTest.kt excerpts across Parts 2–9 — each block compiles as written.
API stability and DSL versioning
Two surfaces carry different contracts:
- The AST is the stable layer. Adding a new BPMN element means a new
data classunderFlowNodeplus a builder plus awhenbranch in the writer — all additive, all source-compatible for readers. Removing or renaming a type is the breaking change; don’t do it without a major version. - The DSL is the ergonomic layer. Renaming
userTaskor changing its builder’s signature breaks every consumer’s compile — treat it as public API. New builder functions are additive; changing existing overloads is not.
Stable identifiers deserve the same discipline. IDs are referenced by defaultFlow, attachedToRef, sourceRef, targetRef, processRef, messageRef, and by DI bpmnElements — renaming a node id is a schema change, not a refactor. Version-generated workflows should carry the definition id (enterpriseProcurement) through generations; a v2 is a new collaboration id or a compatibility migration, not a silent rebake.
Where arbitrary expressions are a security boundary
conditionalFlow and metadata accept free-form strings that land in executable XML. conditionalFlow("f", "g", "t", "${ctx.eval()}") ships an EL expression to an engine — and EL injection in a process definition is a real risk if the DSL input is not trusted:
// If user input reaches a condition string, you have an injection sink.conditionalFlow("f1", "gw", "task", userSuppliedString)The mitigation is the same as for SQL: the DSL consumes programs written by the team, not end-user input. If end users must author workflows, build a UI that produces a constrained JSON schema and a second, validated compilation step into the AST — never let arbitrary strings become Expression bodies unscreened.
Extending the model — the Part-10 shopping list
The architecture is built to grow along specific seams:
| Extension | What it touches |
|---|---|
| Error/compensation/message start events | EventNode subclass + event-definition writer + tests |
| Call activities, reusable subprocesses | ActivityNode subclass + calledElement + validator rule |
| Event subprocesses | EmbeddedSubprocess with triggeredByEvent + a subprocess-level boundary rule |
| Signal/escalation events | New event definitions + bpmn:signal/bpmn:escalation top-level elements |
| Multi-instance tasks | LoopCharacteristics/MultiInstanceLoopCharacteristics on ActivityNode |
| Form schemas for user tasks | formKey → structured formDefinition extension element |
| Timer cycles/cron | already supported — TimerDefinition(cycle=…) |
| A web-based BPMN editor | New frontend that compiles its own model into this AST, or a JSON serializer of the AST itself |
| BPMN import (XML → AST) | A parser producing CollaborationDefinition — the inverse of Part 6 |
| ELK/Graphviz/Sugiyama layout | A second DiagramLayoutEngine implementation |
| Layout overrides per node | layout.shapes merged with a persisted override map keyed by id |
| Camunda output | A CamundaExtensionSerializer implementing the same SPI |
The recurring pattern: a new BPMN element is a FlowNode subtype, a builder function on FlowNodeContainer, a when arm in writeFlowNode, a nodeSize case in the layout engine, and a validator rule if it has semantics. Nothing else.
Testing strategy for a long-lived library
Three tiers, in increasing cost:
- Unit tests on the AST/validator — cheap, fast, and already in place: every diagnostic code gets a purpose-built broken model.
- Golden tests — the generated
procurement.bpmncommitted as a fixture; any behavioral change to serialization or layout requires a deliberate diff review. - Integration tests — the Part 8 pipeline: moddle-parse + headless bpmn-js import + (optionally) a Testcontainers Flowable deployment, run in CI.
The split matters because they catch different things: unit tests catch wrong rules; golden tests catch wrong changes; integration tests catch wrong assumptions about what a viewer accepts.
The generated artifact
One trimmed excerpt of the real procurement.bpmn output — every element class the pipeline produces appears (abridged with … where the file repeats):
<?xml version="1.0" encoding="UTF-8"?><bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmndsl="https://o612.in/bpmn/dsl" xmlns:flowable="http://flowable.org/bpmn" id="enterpriseProcurement_definitions" name="Enterprise Procurement and Supplier Onboarding" targetNamespace="https://o612.in/bpmn/definitions" exporter="o612-bpmn-dsl" exporterVersion="1.0">
<bpmn:message id="rfqRequested"/> <bpmn:message id="supplierQuotationReceived"/> <!-- … five more <bpmn:message> … -->
<bpmn:collaboration id="enterpriseProcurement" name="Enterprise Procurement and Supplier Onboarding"> <bpmn:participant id="buyerOrganization" name="Buyer Organization" processRef="buyerProcurementProcess"/> <bpmn:participant id="supplier" name="Supplier" processRef="supplierInteractionProcess"/> <bpmn:messageFlow id="msgRfqToSupplier" sourceRef="sendRfq" targetRef="receiveRfq" messageRef="rfqRequested"/> <!-- … six more <bpmn:messageFlow> … --> </bpmn:collaboration>
<bpmn:process id="buyerProcurementProcess" name="Buyer Procurement Process" isExecutable="true"> <bpmn:extensionElements> <bpmndsl:processVariables> <bpmndsl:variable name="requisitionId" type="string" required="true"/> <!-- … nineteen more variables … --> </bpmndsl:processVariables> </bpmn:extensionElements> <bpmn:laneSet id="buyerProcurementProcess_laneSet"> <bpmn:lane id="requestingDepartment" name="Requesting Department"> <bpmn:flowNodeRef>requisitionCreated</bpmn:flowNodeRef> <bpmn:flowNodeRef>submitRequisition</bpmn:flowNodeRef> <bpmn:flowNodeRef>correctRequisition</bpmn:flowNodeRef> </bpmn:lane> <!-- … five more lanes … --> </bpmn:laneSet>
<bpmn:startEvent id="requisitionCreated" name="Requisition created"> <bpmn:outgoing>flowStartToSubmit</bpmn:outgoing> </bpmn:startEvent> <bpmn:serviceTask id="validateRequisition" name="Validate Requisition and Supplier" flowable:delegateExpression="${requisitionValidationDelegate}"> <bpmn:incoming>flowSubmitToValidate</bpmn:incoming> <bpmn:incoming>flowCorrectToValidate</bpmn:incoming> <bpmn:outgoing>flowValidateToDecision</bpmn:outgoing> </bpmn:serviceTask> <bpmn:inclusiveGateway id="approvalsSplit" name="Required approvals" default="flowApprovalsBypass"> <bpmn:incoming>flowValidToApprovals</bpmn:incoming> <bpmn:outgoing>flowBudgetBranch</bpmn:outgoing> <bpmn:outgoing>flowLegalBranch</bpmn:outgoing> <bpmn:outgoing>flowApprovalsBypass</bpmn:outgoing> </bpmn:inclusiveGateway> <bpmn:receiveTask id="waitForQuotation" name="Wait for Supplier Quotation" messageRef="supplierQuotationReceived"> <bpmn:incoming>flowRfqToWait</bpmn:incoming> <bpmn:incoming>flowRetryWait</bpmn:incoming> <bpmn:incoming>flowRenegotiationToWait</bpmn:incoming> <bpmn:outgoing>flowQuotationToEval</bpmn:outgoing> </bpmn:receiveTask> <bpmn:boundaryEvent id="quotationSlaExceeded" name="72h supplier SLA" attachedToRef="waitForQuotation"> <bpmn:outgoing>flowSlaToReminder</bpmn:outgoing> <bpmn:timerEventDefinition id="quotationSlaExceeded_timer"> <bpmn:timeDuration xsi:type="bpmn:tFormalExpression">PT72H</bpmn:timeDuration> </bpmn:timerEventDefinition> </bpmn:boundaryEvent> <bpmn:subProcess id="invoiceExceptionHandling" name="Invoice Exception Handling"> <bpmn:incoming>flowMatchToException</bpmn:incoming> <bpmn:outgoing>flowExceptionToRematch</bpmn:outgoing> <bpmn:startEvent id="exceptionStart"><bpmn:outgoing>flowExceptionStart</bpmn:outgoing></bpmn:startEvent> <!-- … interior nodes and flows … --> </bpmn:subProcess> <bpmn:sequenceFlow id="flowBudgetBranch" name="budget approval" sourceRef="approvalsSplit" targetRef="approveBudget"> <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${budgetApprovalRequired}</bpmn:conditionExpression> </bpmn:sequenceFlow> <!-- … all remaining flow elements … --> </bpmn:process>
<bpmn:process id="supplierInteractionProcess" name="Supplier Interaction Process" isExecutable="false"> <!-- … supplier lanes, tasks, event-based gateway … --> </bpmn:process>
<bpmndi:BPMNDiagram id="diagram_enterpriseProcurement"> <bpmndi:BPMNPlane id="plane_enterpriseProcurement" bpmnElement="enterpriseProcurement"> <bpmndi:BPMNShape id="shape_buyerOrganization" bpmnElement="buyerOrganization" isHorizontal="true"> <dc:Bounds x="60" y="60" width="4410" height="974"/> </bpmndi:BPMNShape> <bpmndi:BPMNShape id="shape_requestingDepartment" bpmnElement="requestingDepartment" isHorizontal="true"> <dc:Bounds x="90" y="72" width="4368" height="140"/> </bpmndi:BPMNShape> <bpmndi:BPMNShape id="shape_waitForQuotation" bpmnElement="waitForQuotation"> <dc:Bounds x="1794" y="242" width="120" height="80"/> </bpmndi:BPMNShape> <bpmndi:BPMNShape id="shape_quotationSlaExceeded" bpmnElement="quotationSlaExceeded"> <dc:Bounds x="1899" y="307" width="30" height="30"/> </bpmndi:BPMNShape> <bpmndi:BPMNEdge id="edge_flowSlaToReminder" bpmnElement="flowSlaToReminder"> <di:waypoint x="1929" y="322"/> <di:waypoint x="2911.5" y="322"/> <di:waypoint x="2911.5" y="282"/> <di:waypoint x="3894" y="282"/> </bpmndi:BPMNEdge> <!-- … all remaining shapes and edges … --> </bpmndi:BPMNPlane> </bpmndi:BPMNDiagram></bpmn:definitions>The full file is ~51 KB, two processes (68 + 25 flow elements), 7 message flows, 57 shapes and 60 edges — generated verbatim by the pipeline above, parse-verified by bpmn-moddle and render-verified by bpmn-js.
Known simplifications — stated, not hidden
- Layout is a baseline. Longest-path columns, lane-index rows, one shared return channel per pool. Loop-back edges that span lanes cross lower lanes vertically; message flows don’t avoid nodes. It’s deterministic and correct; it is not ELK.
- No BPMN import. This library writes BPMN; it does not parse it. Round-trip editing is a separate (significant) project.
- No pools-in-pools or nested lanes. Lanes are flat partitions; BPMN’s
childLaneSetis unmodeled. - No data objects, data stores, associations, or group/annotation artifacts. The collaboration is flow-and-message only — enough for the running example, not the whole spec.
- Process variables are metadata. They serialize to a
bpmndslextension block; engines ignore it unless an adapter consumes it. - The supplier process is illustrative. Non-executable pools exist for the diagram; whether
poAcceptedmatches a real supplier system is integration, not modeling. - No token-semantics verification. The validator checks structure, references, scope, direction, and lane/containment invariants — not whether an arbitrary process can deadlock. That is a model-checking problem beyond a structural validator.
What the series actually shipped
One runnable Gradle build — published as pcnixsys/bpmn-dsl-kotlin — organized as the seven modules this chapter described: bpmn-model, bpmn-dsl, bpmn-validation, bpmn-xml, bpmn-layout, bpmn-flowable, bpmn-example. ./gradlew :bpmn-example:run produces a 55 KB .bpmn file that bpmn-moddle parses with zero warnings and bpmn-js renders as a two-pool collaboration. Nineteen tests cover the validator, the DI invariants, the Flowable adapter, determinism, and the golden layout.
The library is not a toy and it is not a product. It is exactly what the series set out to build: a Kotlin internal DSL where the compiler, the validator, and the viewer each enforce a different layer of correctness — and where every stage after the AST is a pure, replaceable, testable pass over immutable data.
Reference implementation
The split itself lives in the repo’s Gradle files: settings.gradle.kts plus one build.gradle.kts per module (for example bpmn-flowable/build.gradle.kts). The API stability contract and the security boundary from this chapter are written down in the README.
If you take one thing
An internal DSL’s value isn’t the fluent syntax — it’s the seam between authoring and output. Mutable builders that compile to an immutable AST, a validator that treats structure as a first-class citizen, and a serializer that never touches the builder layer: that separation is what makes the whole thing worth the indirection.