6.3 DSL Design: Syntax, Semantics, Tooling, and Security Boundaries
The team initially wanted operations staff to configure promotional rules, so they provided a few JSON fields. Six months later, those JSON fields included nested conditions, variable references, priority levels, and expression strings, without a formal grammar, without a type system, and without a debugger, a language had quietly emerged.
The challenge with DSLs isn't crafting a pretty syntax; it's designing a maintainable language product for a specific domain.
First, Determine Whether a Language Is Actually Needed
The following requirements may not necessitate a DSL:
- There are only a small number of fixed options: configuration objects or forms will suffice;
- The primary users are developers: general-purpose libraries or typed APIs are often better;
- Changes involve only parameters, not control structures: data configuration is sufficient;
- Rules must be reviewed and controlled through code reviews and deployments: using the host language directly may offer greater transparency.
A DSL only becomes justified when users need to combine conditions, express computations, reuse rules, and when those changes must remain independent of application releases.
Internal DSLs and External DSLs
Internal DSLs
Internal DSLs leverage the syntax and type system of the host language:
Policy policy = Policy.when(
all(country("CN"), orderAmountAtLeast("200.00")))
.then(applyDiscount("20.00"))
.otherwise(noDiscount());The advantage is full reusability of the host language's compiler, IDE, debugger, and module system. The downside is that the expression is constrained by the host language's grammar, and non-developers still face the underlying code environment. Additionally, callers may bypass the intended composition constraints.
External DSLs
rule summer-sale priority 20 {
when country == "CN" and order.amount >= 200.00 CNY
then discount 20.00 CNY
}External DSLs can closely align with domain-specific language constructs and are independently versioned. However, you must provide a complete toolchain: parser, type checking, error reporting, formatting, editor support, debugging, migration, and security boundaries.
From Text to Execution Requires Clear Stages
Source text
↓ Lexer / Parser
Syntax tree AST
↓ Name resolution and type checking
Validated model
↓ Lowering / planning
Intermediate representation IR
↓ Interpret, compile, or translate
Execution resultBreaking these stages apart prevents the parser from directly executing business logic. Parsing only answers the question "Is the structure valid?"; semantic analysis then answers "Do variables exist? Are types compatible? Can units be added together?"; and the executor receives only a model that has been fully validated.
200.00 CNY + 10 %This may be syntactically valid, but semantically it cannot be directly added. A well-designed DSL should report type errors during rule deployment, never allowing execution to fail halfway through a transaction.
Syntax Should Serve Error Information
When designing syntax, don't just show successful examples, also list the most common mistakes:
- Missing parentheses;
- Unknown fields;
- Comparing strings with numbers;
- Duplicate rule names;
- Unreachable branches;
- Recursive references or circular dependencies.
Error messages should include the location, root cause, and actionable fix suggestions:
promotion.rules:8:21
The type of order.amount is Money<CNY>, so it cannot be compared with the integer 200.
Fix: change to order.amount >= 200.00 CNY"parse error near token" is useful for language authors but offers little value to domain users.
Put Semantics into Domain Models
Don't let interpreters make decisions about string operation names everywhere:
sealed interface Condition permits All, Any, CountryIs, AmountAtLeast {}
record All(List<Condition> children) implements Condition {}
record CountryIs(String countryCode) implements Condition {}
record AmountAtLeast(Money minimum) implements Condition {}The parser should first build a typed AST, then perform semantic validation before lowering it to a simpler IR. This approach enables optimization of the executor without altering the user's grammar and allows multiple frontend syntaxes to share the same semantic core.
The language should also explicitly define:
- Numeric precision, rounding behavior, and currency units;
- Time intervals and time zones;
- How
nullor missing fields propagate; - Rule conflicts and their resolution priorities;
- Evaluation order and short-circuiting behavior;
- Whether external data access is permitted and how failures should be represented.
Executing Untrusted Rules Requires Budgeting
Never pass user-provided expressions directly to the host language's eval. Even if a few function names are blacklisted, reflection, object graphs, and resource consumption can still create escape paths.
A controlled interpreter must at least provide:
- A whitelist of allowed syntax and built-in functions;
- Maximum AST depth and source file size;
- Execution budgets for instruction count, recursion depth, or runtime;
- Limits on memory usage and output size;
- A default environment with no network, file, or process permissions;
- Cancelable execution;
- Audit logging and rule versioning.
When rules originate from untrusted tenants, in-process timeouts are often insufficient for strong isolation. Consider running rules in isolated processes, containers, or dedicated sandboxes, and define narrow, well-scoped protocols between the host and the executor.
Version Evolution Is the Primary Cost of DSLs
Once rules are saved, the old syntax becomes a data format. When releasing a new version, the following questions must be answered:
- Do existing rules continue to execute?
- How are deprecated syntaxes warned about and automatically migrated?
- Can execution results be replayed?
- Do the same rules produce identical outcomes across different interpreter versions?
- What is stored in production logs: the original text, the AST, the IR, or all three plus version metadata?
- Can a rollback operation read and apply the new rules?
It is recommended to store both the language version and content hash for each rule, and to treat compiled outputs as reproducible caches rather than the sole source of truth.
Test a Small Language
Cover at least four layers:
- Parsing Tests: Valid and invalid syntax, error positioning;
- Semantic Tests: Types, names, units, conflicts;
- Execution Tests: Boundary values, short-circuiting, determinism;
- Compatibility Tests: Behavior of historical rules and corpora under new versions.
Additionally, property-based testing can verify:
- Semantics remain unchanged after formatting and re-parsing;
- Intermediate representation (IR) outputs are consistent before and after optimization;
- Execution budgets always terminate even for maliciously deep expressions.
Completion Checklist
Design a minimal DSL for promotional rules that supports only country, order amount, and discount:
- Provide three valid rules and five invalid ones;
- Define type rules for Money and percentage;
- Draw the transformation flow from source code to AST, IR, and final execution result;
- Specify a deterministic method for resolving conflicting rules;
- Establish execution budget, versioning fields, and migration strategies.
If the only successful example is a single paragraph that "looks like natural language," the DSL design is still incomplete.