A Small DSL for Data-Driven Document Generation
When static templates cannot express a variable document structure, a small command language can separate business data from page-generation behavior.
- Software Architecture
- .NET
- DSL
- Document Generation
- Testing
When a template needs to decide its own shape
Static templates work well when a document has a known structure: a title page, a fixed set of tables, and a predictable number of detail pages. The model becomes strained when the structure itself depends on data. One project may need two sections while another needs twenty; each selected item may introduce its own pages, tables, or manufacturing views.
Putting every rule into the UI or a growing collection of template flags makes the system difficult to reason about. A small domain-specific language offers a different boundary. The template describes what to generate, while the application retains control over how pages, sections, queries, and validations actually work.
Architecture credit and public-safe scope
The core architecture discussed in this lesson was designed by Mustafa Şentürk. This article focuses on the reusable architectural ideas and intentionally leaves out production source code, product-specific commands, database schemas, customer data, and internal business rules.
The linked demo is an independent teaching model written with neutral document concepts. It tests the behavior described here without reproducing the original implementation.
Keep the language smaller than the application
A useful document DSL does not try to become a general-purpose programming language. Its vocabulary should stay close to user intent: open a section, iterate over a data set, apply a page template, emit a page, and close the current scope.
Each instruction maps to an application command rather than exposing the document model directly. That mapping creates a stable seam between readable scripts and complex runtime behavior. The language can remain compact even when page construction involves persistence services, geometry, localization, or database access.
- Prefer domain verbs over low-level object manipulation.
- Make block boundaries explicit and easy to validate.
- Keep command registration visible so the supported language is auditable.
- Return structured failures with the line and active scope instead of leaking runtime exceptions.
Treat execution context as a scope system
A flat dictionary is enough until scripts become nested. If an inner loop defines a value with the same alias as an outer loop, overwriting one dictionary entry loses the parent value. Manual cleanup is equally fragile because every exit path must restore exactly the right state.
A stronger model keeps a stack of values for each name and a separate list of values introduced by each scope. Entering a block pushes a scope. Defining a value records it in that scope and on its name stack. Leaving the block removes only the values introduced there, automatically revealing any value that was shadowed below it.
This is lexical scoping expressed with small data structures. It is especially useful when placeholders inside page names or queries must resolve against the current row while still seeing values from parent iterations.
Record instructions, then replay them with data
Iteration introduces the central architectural choice. The engine cannot execute a block only once because its commands must run for every row in a list or query result. Instead, the iteration owner records the instructions inside its block as a reusable queue.
For each value, the engine opens a fresh scope, injects the current item, replays the recorded instructions, and then closes the scope. The document model receives ordinary commands; it does not need to know whether those commands came from a script, a user action, or a replayed iteration.
Recording and replay also avoid cloning partially built document trees. The engine repeats intent, not mutable output, which keeps construction behavior in one place.
Nested loops are the real architecture test
A single loop can succeed even when scope ownership is wrong. Nested iteration exposes the faults: the inner instruction queue may be consumed after its first pass, an alias may leak into the next outer item, or the outer value may disappear when the inner scope closes.
A compact Cartesian-product test is more valuable than a large end-to-end fixture. Given two outer values and two inner values, the engine must produce four uniquely named outputs in deterministic order. That test proves instruction replay, parent-value visibility, child-value cleanup, and queue reuse at the same time.
- Every iteration receives a fresh child scope.
- Inner aliases disappear when their block ends.
- Outer aliases remain visible during every inner replay.
- Recorded instructions remain reusable across all iterations.
Dry-run through ports, not through UI checks
A script that mutates a document should be validated before it changes a real project. Dry-run support becomes much easier when the evaluator depends on narrow ports such as a document target, query source, and template catalog instead of concrete windows or controls.
A validation target can accept the same commands while recording planned operations rather than creating pages. The parser and execution engine therefore follow the real path, but side effects remain replaceable. The same boundary also makes the architecture testable with in-memory data.
Trade-offs and lessons worth reusing
A custom DSL has a maintenance cost. Syntax, diagnostics, compatibility, documentation, and editor support all become product responsibilities. It is justified when users repeatedly need to describe domain workflows that would otherwise become code changes or an unmanageable set of template options.
The broader lesson is not that every document system needs its own language. It is that variable document structure benefits from three explicit concepts: commands that express intent, scoped values that make context predictable, and replayable instruction blocks that separate iteration from mutation.
- Start with the smallest vocabulary that solves a recurring workflow.
- Make scope ownership explicit before adding nested iteration.
- Test behavior with tiny deterministic data sets.
- Keep the evaluator independent from UI and production storage.
- Treat error messages and compatibility as part of the language design.
Share