Tuesday, July 14, 2026

DSLs Enable Reliable Use of LLMs


Modern LLMs possess an incredible capability. They can generate large amounts of code, and
sometimes entire systems, from just a high-level natural language description. An important
assumption here is that the ‘intent’ of what needs to be built is well articulated, using
precise words that LLMs can map to coding building blocks.
However, there are two important points worth noting: the limits of
upfront specification, and how design is discovered through implementation.

The Limits of Upfront Specification

Building large systems involves a great many small design decisions, and these cannot all
be known in advance or driven entirely from a high-level spec. A specification is at best a
starting hypothesis: the real constraints, trade-offs, and edge cases are discovered
iteratively, as we proceed with the implementation. We discussed this at length in an
earlier article, where we called it Upfront Specification Impossibility.
The point is not that specs are worthless, but that the first one is a hypothesis to be
revised, never a finished blueprint.

The natural response is to iterate: refine the spec, generate code, review what comes
back,
and feed what we learn into the next round. That loop works well when each round produces a
small, reviewable change.

Design Is Discovered Through Implementation

Reviewing code, particularly while we are still discovering the design,
is not the same as writing it.
While reviewing the generated code, we review through the chunks validating
if it maps to our intent and looking for possible pitfalls.
But reviewing rarely forces us to wrestle with the design decisions.
Writing code, by contrast, forces us to think through concrete decisions—such as where a responsibility belongs
or what boundaries should be exposed so the design can be extended further.
It is in making those decisions that a design most fully reveals itself.

The programming language and
paradigm we code in shapes the design insight we get. A functional design approach or an
object-oriented design approach reveals different aspects of the design, along with idioms
and patterns that are natural to the paradigm.

So where do LLMs fit in?
I see LLMs playing two roles.
They are a great help while we shape the design and its vocabulary, acting as brainstorming
partners to help us explore the design space and discover the right abstractions.
Once the vocabulary is established, LLMs
work as an excellent natural language interface to it.

Domain Abstractions and DSLs

A useful way to frame this is through Domain Driven Design. Its
core insight is building a shared conceptual model of the domain in code, and then using that
model – which DDD calls a Ubiquitous Language – both to evolve
the codebase and to give the team a vocabulary to think and communicate in. Often, it is
highly effective to build a domain specific language on top of that model: a constrained syntax for
expressing the domain’s concepts and operations. Seen this way, most development is the
process of building a domain model and using it to evolve the system. The LLM plays two
distinct roles depending on whether the domain model already exists.
In this article, I will focus on how Domain-Specific Languages (DSLs) work with LLMs.

Why DSLs work so well with LLMs

It is a common experience that DSLs work well with LLMs.
PlantUML, Mermaid, and Graphviz are domain specific languages for
visual modeling; SQL is a DSL for querying databases; Kubernetes YAML is a DSL for
describing cloud infrastructure.
These are not general-purpose programming languages — they are
deliberately constrained, designed to express a narrow set of concepts in one domain.
And it is no surprise that LLMs are remarkably good at generating
Mermaid diagrams, SQL queries, or Kubernetes manifests
from a plain English description.

My observation is that DSLs make LLMs more reliable because they respond so well to a
few in-context examples. A general-purpose language like Java offers lots of valid ways
to express the same intent. A DSL strips the variation away.
Giving the model a few examples is enough to reliably generate
the correct syntax. It’s worth noting that frontline models
are already heavily exposed to PlantUML or Java fluent interfaces
during training, so they aren’t starting from scratch.
It will be curious to see how smaller, more constrained models perform
when tasked with a truly novel DSL.

For an agent — an LLM running in an autonomous generate-and-check loop rather than
a single shot generation — there is one more benefit. A DSL almost always ships with a deterministic
validator: a parser, a JSON schema, a type checker, or a compiler. The agent can generate a
candidate, run it past the validator, and repair it from the error, all without a human in
the loop. Crucially, the errors are phrased at the level of the domain — “you cannot select
an action before choosing a client” — rather than as a stack trace buried deep in generated
code. DSL’s toolset itself acts as an excellent harness. We will see it concretely in the
Tickloom examples below, where the DSL’s grammar is enforced by the host language’s compiler
and the resulting runs are checked automatically.

It is important to note that this is not a one-size-fits-all solution.
The advantage holds while the DSL stays small and constrained
enough that a few in-context examples can convey its usage.
There is also a real upfront cost in designing and maintaining the
language and its semantic model. The payoff is therefore concentrated in well-factored,
genuinely constrained DSLs backed by a validator.

Example: Using LLMs to generate diagram rich powerpoint presentations

LLMs make it really easy to build custom tools. While teaching
distributed systems, I frequently need to create presentations which mostly have diagrams
explaining distributed operations in a cluster. UML Sequence diagrams have been great for
that but showing a full sequence diagram while explaining the flow of messages through the
cluster is not very useful. I needed a tool to show a sequence diagram step by step in a
powerpoint presentation. With the help of LLMs I was able to build a tool which processes a
YAML describing the presentation structure with references to the PlantUML diagrams and
generates a powerpoint presentation. The PlantUML diagrams are marked with steps, and the
tool generates a separate slide for each step. This made it really easy to create
diagram-rich presentations.

Generate a PlantUML sequence diagram showing a cluster of three nodes athens, byzantium
and cyrene. Put a box to mark the cluster. Actor Alice sends a message “title”, “After Dawn”
to athens, Athens sends message to itself. Put a note to show state ‘title: After Dawn’.
Athens sends message to byzantium which fails. Athens sends to cyrene. Put a note placed to
the right of cyrene. athens then checks with isQuorumReached and returning a synchronous
Success arrow back to Alice.
Put a ‘[step] marker after each message.

This prompt generates the following PlantUML code with step markers:

@startuml
actor Alice

box "Cluster" #lightblue
participant athens
participant byzantium
participant cyrene
end box

'[step]
Alice -> athens: "title", "After Dawn"

'[step]
athens -> athens: save()

note right of athens
  state:
  title: After Dawn
end note

'[step]
athens -[#red]x byzantium: "title", "After Dawn"

'[step]
athens -> cyrene: "title", "After Dawn"

note right of cyrene
  state:
  title: After Dawn
end note

'[step]
athens -> athens: isQuorumReached()

'[step]
athens --> Alice: Success

@enduml
          

I used it to create a series of slides in a powerpoint presentation.
For doing that, I developed a small YAML specification to describe
the presentation structure and the diagrams to be used in each slide.
This allowed me to use LLMs to create presentations describing complex
distributed systems concepts, without having to manually create
animations on the slides. An example prompt to generate
a slide YAML spec is as simple as following.

Create a slide YAML referring to the diagram ‘quorum-write’ with a title ‘Quorum Write
Example’

This generates a slide spec YAML like following:

          - slide:
              title: "Quorum Write Example"
              diagram: "quorum-write"
        

It’s important to note that even if the prompt is saying create a slide YAML, it’s not any random YAML spec. Because the tool
to generate the powerpoint presentation and the YAML specification understood by the tool is
used as a context in the prompt, the LLM is able to generate the correct YAML spec which can
be directly used by the tool to generate the powerpoint presentation.

The full YAML spec
can be viewed at this
Github repo

Notice that the LLM played two different parts in this single example. First it was a
co-designer — helping shape the step-marked PlantUML extension and the slide YAML on top of
existing PlantUML tooling. Then, once that small DSL existed, it became the natural-language
interface that turns an English request into a valid spec. We will come back to this
division of labour at the end of the article.

Building the Semantic Model

The example we covered in the previous section was straightforward.
The YAML was used as a carrier syntax, and
I process its parsed syntax tree directly,
effectively using the syntax tree itself
as my Semantic Model (though this couples the syntax to the execution semantics).
But in more complex domains, like distributed systems, we need more complex semantic models
to represent the concepts
in the domain and the design decisions we have made in the codebase.
Let’s look at an example based on a small framework I built to quickly build and test
distributed systems.

Example: Tickloom — a semantic model for distributed systems

Implementing distributed systems such as quorum-based key-value stores or consensus
protocols like Raft and Paxos is a daunting task.
Even if implementation is guided incrementally through prompts, specifications, or carefully
constructed .md skill files, the asynchronous runtimes still expose an overwhelming
space of possible implementation decisions. Threading models, networking patterns, storage
coordination, retry behavior, and timing semantics all remain entangled within the generated
code.
The problem is not merely code generation complexity, but verification complexity.
The resulting state space created by all possible interleavings across thread scheduling,
network delays, process pauses, and clock skew becomes so large that systematically
reviewing and validating correctness across all interacting behaviors is nearly impossible.
This is the reason we see that Jepsen tests find bugs even in the most battle-tested
distributed systems.

This is exactly where a semantic model is beneficial. Tickloom is a small framework I built
to construct and test distributed algorithms. Its abstractions are not a generic runtime;
they are a set of design decisions about how a distributed process behaves. Every
node runs in a single-threaded tick loop: each call to tick() advances a
logical clock by one and processes pending work in a fixed, deterministic order (network,
then message bus, then process, then storage). Time is measured in ticks, not milliseconds.
Messages are plain Java records. Coordination across replicas is expressed through a
Replica
base class that already knows about peers, broadcasts, and quorums.

Threading, timing, network delivery are no longer open questions to be
re-decided in every prompt.
What remains for the algorithm author is the actual protocol logic. A quorum replica, for
instance, is just a set of message handlers expressed in the framework’s vocabulary.

Because the framework supplies the vocabulary — Replica, quorumRequest
,
countResponseIf, MessageType, Handler — a
prompt can stay at the level of the protocol rather than the plumbing:

Using the Tickloom Replica abstraction, implement a quorum-based key-value store.
A client GET should collect values from a majority and return the one with the highest timestamp
(last-writer-wins). Apply a write locally only if its timestamp is newer than the
stored one.

This high level description produces code like following:

@Override
protected Map<MessageType, Handler> initialiseHandlers() {
    return Map.of(
            LWWMessageType.CLIENT_SET_REQUEST, this::handleClientSetRequest,
            LWWMessageType.CLIENT_GET_REQUEST, this::handleClientGetRequest,
            LWWMessageType.INTERNAL_SET_REQUEST, this::handleInternalSetRequest,
            LWWMessageType.INTERNAL_GET_REQUEST, this::handleInternalGetRequest,
            LWWMessageType.INTERNAL_SET_RESPONSE, this::handleInternalSetResponse,
            LWWMessageType.INTERNAL_GET_RESPONSE, this::handleInternalGetResponse
    );
}

private void handleClientGetRequest(Message message) {
    var req = deserializePayload(message.payload(), ClientGetRequest.class);
    var internalReq = new InternalGetRequest(req.key());

    this.<InternalGetResponse>quorumRequest(LWWMessageType.INTERNAL_GET_REQUEST, internalReq)
            .countResponseIf(r -> true) // any response is fine, just need a majority
            .send()
            .whenComplete((responses, error) -> {
                if (error != null) {
                    send(createMessage(message.source(), 
                                       message.correlationId(), 
                                       new ClientGetResponse(req.key(), null, false),
                                       LWWMessageType.CLIENT_GET_RESPONSE));
                    return;
                }

                byte[] highestValue = null;
                long highestTimestamp = -1;

                for (InternalGetResponse r : responses.values()) {
                    if (r.value() != null && r.timestamp() > highestTimestamp) {
                        highestTimestamp = r.timestamp();
                        highestValue = r.value();
                    }
                }

                boolean found = highestValue != null;
                send(createMessage(message.source(), 
                                   message.correlationId(), 
                                   new ClientGetResponse(req.key(), highestValue, found),
                                   LWWMessageType.CLIENT_GET_RESPONSE));
            });
}

source

The semantic model itself acts as a context. The prompt names
concepts that exist as concrete types in the codebase, so the LLM is not inventing a
threading model or a networking layer — it is filling in protocol logic against a fixed,
well-understood substrate.

Even good abstractions help — without a DSL

A DSL is one end of the spectrum, and it is not easy to
build. Before reaching for a language of your own it is worth noticing that a clean set of
abstractions is already a lighter version of the same idea — and, just as the framework
supplied the vocabulary for the quorum store above, a library’s named types and methods are
themselves a vocabulary the model can be grounded in. Tickloom’s semantic model is really
just four such seams — Process/Replica for compute and message
handling, Network for communication, Storage for persistence, and
a logical Clock for time — and that decomposition does most of the work with no
new syntax at all.

This is why abstractions, not just DSLs, pair well with LLMs.
A prompt to “implement Raft as a Tickloom Replica” has limited state space to explore.
The existing QuorumReplica can be used in context as a worked example.

Example: Building a DSL for testing distributed system scenarios

Implementing the algorithm is one thing; exercising it is another.
The subtle bugs in distributed systems live in specific orderings: a write that
replicates to one node before a reader’s quorum shifts, a partition that heals at just the
wrong moment, two coordinators whose clocks have drifted apart. Writing such a scenario
directly against the testkit involves juggling futures, and
manual tick() loops. Here is a clock-skew scenario written that way:

Cluster cluster = new Cluster()
        .withProcessIds(Arrays.asList(ATHENS, BYZANTIUM, CYRENE))
        .useSimulatedNetwork()
        .build(QuorumReplica::new);

cluster.start();

try {
    cluster.tickUntil(cluster::areAllNodesInitialized);

    cluster.setTimeForProcess(ATHENS, 1000L);
    cluster.setTimeForProcess(BYZANTIUM, 2000L);

    QuorumReplicaClient alice = cluster.newClientConnectedTo(ALICE, ATHENS, QuorumReplicaClient::new);
    QuorumReplicaClient bob = cluster.newClientConnectedTo(BOB, BYZANTIUM, QuorumReplicaClient::new);
    QuorumReplicaClient reader = cluster.newClientConnectedTo(READER, ATHENS, QuorumReplicaClient::new);

    TickCompletableFuture<SetResponse> bobWrite = bob.set(KEY.getBytes(StandardCharsets.UTF_8), "B".getBytes(StandardCharsets.UTF_8));
    cluster.tickUntilComplete(bobWrite);

    TickCompletableFuture<SetResponse> aliceWrite = alice.set(KEY.getBytes(StandardCharsets.UTF_8), "A".getBytes(StandardCharsets.UTF_8));
    cluster.tickUntilComplete(aliceWrite);

    TickCompletableFuture<GetResponse> read = reader.get(KEY.getBytes(StandardCharsets.UTF_8));
    cluster.tickUntilComplete(read);

    assertEquals("B", new String(read.getResult().value(), StandardCharsets.UTF_8));
} finally {
    cluster.close();
}

source

The intent — “Bob writes through Byzantium, Alice writes through Athens, a reader sees Bob’s
value because Byzantium’s clock was ahead” — is buried under mechanics.
This is also a hard to verify code: there are dozens of incidental decisions (when to tick, how to encode
bytes, which factory overload to call) for an LLM to get subtly wrong,
and a reviewer must check every one of them.

So on top of the semantic model I built an internal DSL whose vocabulary is the
vocabulary of the scenario itself — servers, clients, who is connected to whom, what each
client does, and which faults are in effect while it does it. The same scenario becomes:

        Scenario<QuorumReplicaClient> scenario =                        
                        QuorumStepBuilder.scenario("LWW lost update via server clock skew")
                        .servers(ATHENS, BYZANTIUM, CYRENE)
                        .clients(ALICE, BOB, READER)
                        .client(ALICE).connectedTo(ATHENS)
                        .client(BOB).connectedTo(BYZANTIUM)
                        .given(g -> g.serverTimeAt(ATHENS, 1_000L)
                                     .serverTimeAt(BYZANTIUM, 2_000L))
                        .steps(s -> {
                            s.client(BOB).writes(KEY, "B").expectSuccess();
                            s.client(ALICE).writes(KEY, "A").expectSuccess();

                            s.client(READER).reads(KEY)
                                    .expectResponse(v -> "B".equals(v));
                        });

source

The DSL is a thin, declarative surface that compiles down to a pure intermediate
representation — a Scenario made of Steps, where each step carries
an Action (a read or write) and optional ClusterEvents (faults
like partitions and message delays). Faults read as English too:
partition(BYZANTIUM).from(CYRENE)
, reconnect(BYZANTIUM), delay(INTERNAL_SET_REQUEST).from(ATHENS).to(BYZANTIUM,
CYRENE).byTicks(100)
. The grammar is enforced by the type system through progressive
interfaces — you cannot declare a step before the topology, or an action before selecting a
client — so whole classes of malformed scenarios simply do not compile.
Because the DSL is an internal DSL built in Java, the host compiler validates the grammar for free, and a malformed
generation comes back as a compile error pinned to exactly the illegal step rather than a
runtime surprise.

Once the DSL exists, a natural
language description of a failure scenario maps almost directly onto it. A prompt like:

Using the Tickloom scenario DSL, write a scenario reproducing the DDIA §10.6
non-linearizable quorum read. A writer connected to Athens sets the key, then updates
it while replication from Athens to the other replicas is delayed. Alice, reading through
Byzantium, is forced onto a quorum that includes Athens and sees the new value; Bob, reading
later through a quorum of Byzantium and Cyrene, still sees the old value.

yields a scenario that stays entirely within the DSL’s constrained vocabulary:

Scenario<QuorumReplicaClient> scenario =
        QuorumStepBuilder.scenario("Non-linearizable quorum read")
                .servers(ATHENS, BYZANTIUM, CYRENE)
                .clients(WRITER, ALICE, BOB)
                .client(WRITER).connectedTo(ATHENS)
                .client(ALICE).connectedTo(BYZANTIUM)
                .client(BOB).connectedTo(BYZANTIUM)
                .steps(s -> {
                    // Writer sets the key to VOLD initially and it replicates fully
                    s.client(WRITER).writes(KEY, VOLD).expectSuccess();

                    // Writer updates to VNEW, but replication from Athens is delayed
                    s.client(WRITER).writes(KEY, VNEW)
                            .whileClusterEvent(delay(QuorumMessageTypes.INTERNAL_SET_REQUEST)
                                    .from(ATHENS).to(BYZANTIUM, CYRENE).byTicks(100))
                            .expectSuccess();

                    // Alice reads through Byzantium. Force quorum to include Athens by partitioning Cyrene.
                    // She will read VNEW from Athens.
                    s.client(ALICE).reads(KEY)
                            .whileClusterEvent(partition(BYZANTIUM).from(CYRENE))
                            .expectResponse(v -> VNEW.equals(v));

                    // Bob reads later through Byzantium. Force quorum to include Cyrene (and exclude Athens).
                    // The delayed VNEW replication hasn't arrived at Cyrene, so Bob reads VOLD.
                    s.client(BOB).reads(KEY)
                            .whileClusterEvent(reconnect(BYZANTIUM))
                            .whileClusterEvent(partition(BYZANTIUM).from(ATHENS))
                            .expectResponse(v -> VOLD.equals(v));
                });

ScenarioResult result = scenario.run();

source

Because the surface is so small — and the space of valid code it can generate is so much
smaller than the space of valid Java programs — the LLM has very little room to hallucinate,
and a reviewer can read the result as a description of an experiment rather than as code to
be audited line by line. Even if LLM hallucinates, the internal DSL
will fail to compile allowing LLM to correct the errors made.

Two phases working with LLMs

A pattern emerges from the examples discussed above: in every one of them the LLM was useful
in two quite different ways.

The first phase is designing the abstraction or DSL itself. Here the LLM is best
treated as a brainstorming partner rather than a code generator. As argued at the start of
this article, the design decisions that make up a semantic model cannot all be specified
upfront — we discover the constraints, trade-offs, and edge cases as we implement them. So this
phase is inherently iterative and feedback-driven: you propose a structure, try it against a
real case, see where it turns out awkward, and feed what you learned back into the next
round. The LLM speeds that loop up — it sketches alternatives, critiques a design, ports an
idea from one language to another — but you stay firmly in the driver’s seat, because these
are exactly the decisions you need to understand and own. The structures that make a DSL
pleasant to use, like progressive interfaces that make an illegal scenario fail to compile
or a semantic model kept separate from the builder that produces it, are the kind of thing
you converge on by iterating, not by writing a specification and generating code.

The second phase begins once the abstraction or DSL is in place. Now what the LLM does
changes: it becomes a natural-language interface to what you have built.
The prompts in this article are examples — “implement a quorum store as a Tickloom
Replica”, “write a scenario
reproducing the DDIA §10.6 read”, “create a slide YAML for this diagram”. In each case the
English description maps almost directly onto the vocabulary you defined, and the LLM is a
dependable generator precisely because the abstraction supplies both the context that
grounds the prompt and the harness that checks the result.

The DSL as the Source of Truth

There is a growing trend of treating prompts as the primary source of truth. A well-designed
DSL fundamentally changes this dynamic. One of the key advantages I observe of working with DSLs is
that the generated program itself often becomes the artifact that humans maintain. Because a
DSL is dense, expressive, and largely free of incidental boilerplate, it captures the
essential intent of the solution in a form that remains readable long after generation. If
an LLM generates a Tickloom failure scenario from a natural language request, the resulting
scenario is already expressed in the vocabulary of the domain. If the scenario needs to
change next month, there is no need to recover the original prompt and regenerate
everything. The DSL has enough context for LLM to know the intent and work with it. The
enduring asset is not the prompt, but the DSL and the semantic model.




Source link

Speak Your Mind

*


*