DSL Design

On this page

A Domain-Specific Language (DSL) is a language tailored to a specific domain. Its value lies in allowing domain concepts, constraints, and composition methods to appear directly in the expression, rather than simply turning a standard API into a shorter chain of method calls.

Before designing the language, collect typical tasks and errors: What do users want to express? Which combinations must be rejected? How often do rules change? Who maintains them? How are failures located? The syntax should serve these needs.

Design Semantics First: Using Price Rules as an Example

Consider the rule order.total >= 100 USD. Before choosing between braces or indentation, you need to clarify whether total refers to the tax-inclusive or tax-exclusive amount, whether a missing value is an error or zero, whether different currencies can be compared, and whether values use integer cents or decimal fixed-point numbers.

If these are not defined, the system cannot provide stable answers even if the parser correctly accepts the input. The "domain-specific" nature of a DSL is first reflected in its types, constraints, and execution rules.

A Rule DSL: Domain checks begin after syntax Input Rule order.total >= 100 USD 1 Parse structure and record source locations Comparison expression: field, operator, amount literal 2 Parse names and check domain types Does total exist? Are amount currencies compatible? 3 Generate stable rule representation Explicitly handle missing values, rounding, and errors 4 Interpret or compile for execution Budget, diagnostics, and result tracking remain consistent 5 Whether syntax is valid and whether the rule has domain meaning are two independent checks. For the same rule, false, true, and input error are three outcomes order.total >= 100.00 USD Compiled to a threshold of 10000 cents, currency USD 9999 cents USD → false Normal case: condition not met 10000 cents USD → true Boundary value satisfies ≥ Missing / EUR → error Do not silently convert to zero or auto-convert currency Domain contracts must remain identical in the parser, visual editor, and execution backend; they are not determined by syntax appearance.

Implementing this Rule as a Minimal Executable Language

This example only supports "field >= non-negative amount currency". The domain model specifies that order.total is the determined order total in cents; only USD is accepted, no currency conversion is performed, and a missing total reports an error. The amount allows up to 9 integer digits and 2 decimal places, avoiding leaving arbitrary-length numbers and ambiguous rounding rules to the executor.

from dataclasses import dataclass
import re

@dataclass(frozen=True)
class Rule:
    field: str
    cents: int
    currency: str
    span: tuple[int, int]

class RuleError(ValueError):
    pass

PATTERN = re.compile(
    r"(?P<field>[a-z][a-z0-9_.]*)\s*>=\s*"
    r"(?P<whole>[0-9]{1,9})(?:\.(?P<fraction>[0-9]{1,2}))?"
    r"\s+(?P<currency>[A-Z]{3})"
)

def compile_rule(source):
    if len(source) > 256:
        raise RuleError("rule too long")
    match = PATTERN.fullmatch(source)
    if match is None:
        raise RuleError("expected FIELD >= AMOUNT CURRENCY")
    field = match["field"]
    if field != "order.total":
        raise RuleError(f"unknown field at {match.span('field')}: {field}")
    if match["currency"] != "USD":
        raise RuleError(f"unsupported currency at {match.span('currency')}")
    fraction = (match["fraction"] or "").ljust(2, "0")
    cents = int(match["whole"]) * 100 + int(fraction)
    return Rule(field, cents, "USD", match.span())

def evaluate(rule, order):
    if "total_cents" not in order or order["total_cents"] is None:
        raise RuleError("missing order.total")
    amount = order["total_cents"]
    if type(amount) is not int or amount < 0:
        raise RuleError("total must be nonnegative integer cents")
    if order.get("currency") != rule.currency:
        raise RuleError("currency mismatch")
    return amount >= rule.cents

rule = compile_rule("order.total >= 100.00 USD")
assert rule.cents == 10000
assert not evaluate(rule, {"total_cents": 9999, "currency": "USD"})
assert evaluate(rule, {"total_cents": 10000, "currency": "USD"})
assert compile_rule("order.total >= 0.1 USD").cents == 10
for source in ["order.totla >= 100 USD", "order.total >= 1 EUR",
               "order.total >= 0.001 USD", "order.total >= 1 USD trailing"]:
    try:
        compile_rule(source)
    except RuleError:
        pass
    else:
        raise AssertionError("invalid rule accepted")
for order in [{}, {"total_cents": None},
              {"total_cents": True, "currency": "USD"},
              {"total_cents": 10000, "currency": "EUR"}]:
    try:
        evaluate(rule, order)
    except RuleError:
        pass
    else:
        raise AssertionError("invalid input accepted")

This restricted language has no nested expressions, so a full-match regex is sufficient to express the current syntax; adding parentheses, and/or, and precedence requires designing explicit syntax and a parser, rather than continuously concatenating regexes to mask structure. fullmatch also ensures no trailing text is ignored after the rule.

Amount parsing directly converts decimal characters to integer cents; 0.1 is interpreted as 10 cents, and 0.001 is rejected rather than silently rounded. Execution uses type(amount) is int because Python's bool is a subclass of int, and this domain does not accept true as one cent. The rule semantics here are explicitly defined by examples, without borrowing the host language's implicit type conversion.

Diagnostic Precision Expands with Language Capability

Current field or currency errors preserve the match span, but syntax errors only provide a global hint; this is a minimal executor, not a full editor diagnostic. For interactive editing, you can preserve spans for both tokens and nodes, distinguishing between "missing amount after >=", "unsupported currency", and "rule incomplete", then map diagnostics back to the source code.

InputLayer to HandleWhy Not All Return False
Total 99.99 USDExecution returns falseValid data, rule does not match
order.totlaName check rejectsA typo makes the rule never take effect
0.001 USDCurrent amount syntax rejectsPrecision policy does not allow this input yet
Total missingExecution input errorMissing data differs from being below the threshold
Total currency EURDomain compatibility errorNo implicit currency conversion rule exists

If the business wants missing values to become unknown and use three-valued logic for and/or, this should be treated as another explicit language semantic. You cannot simply catch all exceptions in a backend and return false; otherwise, two backends will produce different answers for the same rule.

Parsing Succeeds, Why Can't All Failures Be Treated as False?

An editable rule is a better test of language boundaries than four "success/failure" buttons. The following inputs will actually go through full matching, field checking, currency checking, and execution input checking, before generating a restricted CompareAmount representation. Modifying the spelling, writing three decimal places, or leaving the total missing should fall into different error categories; only a valid amount below the threshold returns false.

Preparing the visual
Parsing Succeeds, Why Can't All Failures Be Treated as False?

Syntax, fields, units, and runtime data are different contracts. 99.99 failing the threshold is false; unknown fields or missing data are errors; amounts convert directly to integer cents, bypassing floating-point rounding.

Internal DSL: Borrowing Host Syntax and Tools

Internal or embedded DSLs leverage host mechanisms such as functions, operators, types, and closures to represent domain operations. For example, a query builder can combine field comparisons into an expression tree, which is eventually generated as parameterized SQL by the backend.

The host language's type checking is a foundational capability, but domain checking is not free. Only by encoding field types, operation constraints, and state transitions into the API can the compiler help reject corresponding misuse; directly accepting any string via where(string) does not automatically gain field name checking or SQL safety.

Builders can either execute immediately or save an intermediate representation for interpretation or compilation. Method chaining is just surface syntax; you cannot infer that it builds an AST from the chained style alone.

Expression Templates: Delayed Evaluation Also Requires a Correct Cost Model

Matrix libraries can use expression templates to preserve the structure of B + C * D, choosing loop fusion or optimized evaluation order. However, matrix multiplication, aliasing, and reuse may make temporary results more correct or efficient; you cannot claim that all expressions avoid allocating temporary matrices or automatically yield optimal code. Eigen Lazy Evaluation and Aliasing

Macros can also embed token syntax in the host language that requires separate parsing. Therefore, "internal DSLs necessarily have no parser" is not a general rule. Whether semantic checking occurs at the macro stage or in subsequent type checking depends on the interface design.

External DSL: Having Its Own Input Language

Standalone syntax is suitable for scenarios requiring cross-host language usage, targeting domain users, or strictly limiting expressive power. It needs to handle lexing, syntax, locations, diagnostics, and versioning itself, but does not need to generate machine code.

Common backends include directly interpreting the AST, lowering to a rule IR, generating SQL or host source code, generating bytecode, or compiling to machine code. The choice depends on execution frequency, deployment, debugging, portability, and restriction capabilities, not simply "compile if you want speed, transpile if you want debugging."

A configuration snippet can be checked at load time and repeatedly interpreted, which may already meet performance requirements; a compute-intensive DSL might be worth investing in IR optimization and specialized backends. Both are complete and reasonable designs.

Layering Frontend Checks

LayerExample IssueAppropriate Diagnostic
LexicalIllegal characters in amount literalPoint out the specific character and allowed format
SyntaxComparison operator missing right-hand valuePoint out the missing item and current location
Name Resolutionorder.totla does not existPoint out the unknown field, optionally suggest candidate spellings
Type and UnitComparing amount with stringExplain types on both sides and allowed operations
Domain ConstraintsCurrent rule does not allow accessing sensitive personal fieldsExplain the restriction and rule context
ExecutionData missing or budget exhaustedReturn a controlled error that can be linked to source code

Nodes should preserve source locations, and necessary mappings should be retained when lowering to IR. This allows generated code or runtime errors to point back to the rule written by the user, rather than just displaying internal temporary variables.

Types, Effects, and Resource Boundaries

Limiting expressive power makes verification easier. For example, prohibit arbitrary network and file calls, providing only explicitly defined pure functions; limit loops or use bounded set operations; set step, time, and memory budgets for execution.

But "being a DSL" does not mean it is already safely isolated. When embedding host execution, macro expansion, or calling external functions, you must still explicitly define their permissions and side effects. If rule sources are untrusted, simply concatenating strings and handing them to the host eval is not a sandbox.

For a DSL that generates SQL, values should be passed via bound parameters; identifiers should be handled according to syntax, quoting, and allowed sets. Parameterized values cannot directly replace arbitrary table names or column names.

Intermediate Representation Allows Multiple Entrances to Share Semantics

You can have a visual rule editor, a host API, and an external text parser all generate the same domain IR. Type checking, optimization, and execution share this representation, avoiding different semantics from three different entrances.

For example, the IR node CompareAmount(field, amount, currency) is easier to check, format, and serialize than an unparsed string fragment. When exporting the IR, you should also define a version; do not treat internal memory structures as a long-term compatible protocol.

Version Evolution: Rules Are Also Long-Term Data

After the language goes live, field deletions, default value changes, rounding rule adjustments, or keyword additions may change the meaning of old rules. You can record the language version for rules, provide parsing and migration strategies, and retain normalized representations and reproducible test cases.

Backward compatibility cannot just check "old text still parses." If an old rule parses to a different result, it is also a semantic break. Regression test cases should cover boundary values, missing values, and error inputs, and compare results between the interpreter and compilation backend.

Compare Results and Error Categories Before Migration

Assume the old version defaults missing amounts to zero, while the new version reports an error. Old text can still parse, but behavior has already changed. Migration test cases should save input data, rule version, expected results, or error categories, not just compare whether ASTs look the same.

The same set of cases can be handed to the interpreter, the SQL-generating backend, and other compilation backends. Normal results must be consistent; handling of missing data, currency mismatches, and precision boundaries must also be consistent. Normalized formats can change spacing or amount display, but cannot silently change the precision policy for 0.1 or the boundary for >=.

Quick Selection Guide

RequirementSuggested Starting Point
Developers use it within the same language, standard APIs are easy to expressFunctions, type-safe builders, or operator interfaces
Repeatedly generating host code, needing to read syntax structureMacros or code generators
Cross-language distribution, domain user editing, independent version managementExternal syntax and stable semantic model
Multiple entrances sharing rule capabilitiesUnified domain IR, connected to different frontends

Internal DSLs do not necessarily evolve into external languages, and external languages are not the "higher-level" endpoint. Start with minimal expressive power to cover real tasks, then expand based on explicit needs.

References

Keywords: DSL, embedded language, fluent API, expression templates, domain semantics, interpreter, code generation, language evolution