ADR 020: C# Output — Templated Emission, Constants Only
Status
Accepted
Context
ADR 005 added output languages behind a strategy map of per-language builders, and claimed that builders "generate identical API structures with language-specific syntax". That held while the only targets were Kotlin and Java. Adding C# (#89) breaks the claim in two places, and this ADR records why.
The motivation is a polyglot setup the tool previously stopped short of: the engine is hosted in a Java service while the workers are .NET. Those workers referenced BPMN ids, message names and job types as hardcoded strings — exactly the drift bpmn-to-code removes on the JVM side.
Two facts about the current codebase shaped the decision:
- The generated JVM API is not a bag of strings. Since typed navigation landed, every section except
ServiceTaskswraps its values in types frombpmn-to-code-runtime(ElementId,MessageName,VariableName.Input,BpmnError,BpmnTimer,InputOutputMapping, …).Flowgoes further — each node extendsAbstractFlowNodeand implementsHasSuccessors/FlowScope. bpmn-to-code-runtimeis a JVM artifact (ADR 014) with no C# counterpart, and no NuGet publishing pipeline exists.
So a C# target could not mirror the JVM shape without first designing, publishing and versioning a C# runtime package — a considerably larger effort than the generator itself.
Decision
Ship C# as a beta that emits the constants sections only, as a static class of const string fields written to a .cs file, with packagePath used verbatim as the namespace.
- No runtime dependency. Every value is a plain
const string. Multi-field values (errors, timers, call-activity mappings) become a nestedstatic classof consts rather than a wrapper type. The generated file compiles in any project with nothing added to it. FlowandVariantsare not generated. Both are navigation over the process graph and every node of them derives from the JVM runtime. A partial navigation API would not compile, so it is omitted rather than half-emitted.- Templated emission, scoped to C#. ADR 005 rejected template-based generation because it loses the type safety of the poet libraries. That trade-off was between available libraries; for C# there is no poet equivalent on the classpath.
CSharpWriteris a small indent-aware text writer used only byCSharpProcessApiBuilder; Kotlin and Java keep KotlinPoet / JavaPoet. - PascalCase identifiers, derived from the existing
toUpperSnakeCase()form so the sanitising already done there — stripping expression syntax, collapsing.-:, guarding a leading digit — applies to C# too. Because the result always starts with a letter or_, it can never collide with a C# keyword. - Variable direction moves into documentation. The JVM APIs encode it in the type (
VariableName.Input); C# carries it in an XML doc comment on each constant, so it still surfaces in IntelliSense.
Correctness is gated by compiling the output with the real C# compiler (CSharpCompilationTest, warnings escalated to errors) for all three engines, alongside the usual golden-file fixture. The test skips itself when no .NET SDK is present so contributors without one can still run the suite; CI installs the SDK.
Consequences
Positive
- .NET workers get the same rename-safety the JVM side has, without waiting on a C# runtime package.
- Generated C# has zero dependencies, which makes it trivial to vendor into an existing project.
- The strategy map from ADR 005 absorbed a third language with one map entry;
ApiObjectSelectionand the navigation IR needed no change at all.
Negative
- Output shape now differs per language, not just syntax — ADR 005's "identical API structures" no longer holds across all three targets.
- No compile-time direction enforcement for variables in C#; a doc comment is advisory.
CSharpWriterproduces no AST, so C#-specific validity (unlike KotlinPoet/JavaPoet output) rests on the compilation test rather than on the emitter.- The Web app's "include library sources" affordance is JVM-only and is hidden for C#.
Alternatives Considered
Publish a C# runtime package and reach full parity (Rejected for the beta)
- Requires designing the C# equivalents of 15+ runtime types, a NuGet account, publishing and a versioning policy coupled to the generator's. Large enough to be its own effort; deferred as a follow-up.
Emit the runtime types inline into the generated file (Rejected)
- Makes each generated file self-contained, but duplicates the types across every generated process and amounts to designing the C# runtime informally, with no version to reason about.
Generate constants but keep Flow as plain strings (Rejected)
- The value of
Flowis the compiler-verified path; without the typed nodes it degrades to a second, differently-shaped copy ofElements.