Software Development

The Power of Domain-Specific Languages in the Age of Generative AI

Modern Large Language Models (LLMs) have demonstrated an extraordinary capacity to generate substantial amounts of code, and at times, entire software systems, from high-level natural language descriptions. This capability hinges on the critical assumption that the "intent" behind the desired system is articulated with precision, enabling LLMs to map these descriptions to fundamental coding building blocks. However, a deeper examination reveals two crucial limitations inherent in this process: the inherent constraints of upfront specification and the profound insight that design is often discovered through the act of implementation.

The Unavoidable Limits of Upfront Specification

The creation of complex software systems invariably involves a multitude of granular design decisions. These decisions cannot be fully anticipated at the outset or dictated solely by a high-level specification. A specification, at best, serves as an initial hypothesis. The true constraints, intricate trade-offs, and subtle edge cases emerge iteratively as the implementation progresses. This concept, previously explored under the banner of "Upfront Specification Impossibility," underscores that while specifications are valuable, the initial one is merely a starting point for revision, not an immutable blueprint.

The natural inclination in response to this iterative discovery is to refine the specification, generate code, meticulously review the output, and then feed the learnings back into the subsequent development cycle. This feedback loop proves effective when each iteration yields a manageable and reviewable change.

Design Discovered Through the Act of Implementation

The process of reviewing code, particularly during the nascent stages of design discovery, differs significantly from the act of writing it. Code reviews typically involve validating that generated code aligns with the intended functionality and identifying potential pitfalls. However, reviews rarely compel developers to grapple with the fundamental design decisions. In contrast, the act of writing code necessitates concrete decision-making – determining where responsibilities should reside, defining the boundaries of exposed interfaces for future extensibility, and so forth. It is within these decision-making processes that a design truly reveals its complexities and nuances.

The choice of programming language and paradigm profoundly influences the insights gained during the design process. For instance, adopting a functional or an object-oriented approach can highlight distinct facets of a design, along with idioms and patterns that are intrinsically suited to each paradigm.

This brings us to the pivotal question: where do LLMs fit into this intricate landscape? LLMs can be instrumental in two key roles. Firstly, they serve as invaluable brainstorming partners during the design phase, aiding in the exploration of the design space and the identification of appropriate abstractions. Once a foundational vocabulary is established, LLMs then transition to acting as sophisticated natural language interfaces to that established model.

The Crucial Role of Domain Abstractions and Domain-Specific Languages (DSLs)

A powerful framework for understanding this dynamic is provided by Domain-Driven Design (DDD). DDD’s core tenet is the construction of a shared conceptual model of the domain within the codebase, utilizing this model – known as a Ubiquitous Language – to both evolve the codebase and equip the team with a common vocabulary for thought and communication. Often, building a Domain-Specific Language (DSL) atop this model proves highly effective. A DSL offers a constrained syntax specifically designed to express the concepts and operations within a particular domain. From this perspective, much of software development can be viewed as the process of building a domain model and subsequently employing it to evolve the system. The role of an LLM is bifurcated, depending on whether a domain model is already in place. This article will focus on the synergistic relationship between Domain-Specific Languages (DSLs) and LLMs.

The Remarkable Synergy Between DSLs and LLMs

It is a widely observed phenomenon that DSLs exhibit exceptional performance when integrated with LLMs. Languages like PlantUML, Mermaid, and Graphviz are domain-specific languages used for visual modeling; SQL serves as a DSL for database querying; and Kubernetes YAML is a DSL for defining cloud infrastructure. These are not general-purpose programming languages. Instead, they are intentionally constrained, engineered to articulate a narrow set of concepts within a specific domain. Consequently, it is unsurprising that LLMs demonstrate remarkable proficiency in generating Mermaid diagrams, SQL queries, or Kubernetes manifests from plain English descriptions.

A key observation is that DSLs enhance the reliability of LLMs because these models respond exceptionally well to a limited number of in-context examples. In contrast, general-purpose languages like Java offer a vast array of valid approaches to express the same intent. DSLs, by design, strip away this variability. Providing the LLM with a few illustrative examples is often sufficient to reliably generate the correct syntax. It is important to note that frontline LLMs have likely been extensively exposed to languages like PlantUML or Java fluent interfaces during their training, meaning they are not starting from a completely blank slate. The performance of smaller, more constrained models when tasked with a truly novel DSL will be a fascinating area to observe.

For agents, which operate within an autonomous generate-and-check loop rather than a single-shot generation, an additional benefit emerges. A DSL almost invariably includes a deterministic validator – be it a parser, a JSON schema, a type checker, or a compiler. The agent can generate a candidate output, submit it to the validator, and then rectify any errors without human intervention. Crucially, the error messages are articulated at the domain level – for instance, "you cannot select an action before choosing a client" – rather than as deeply nested stack traces within generated code. The tooling associated with a DSL itself acts as an excellent harness. This will be concretely illustrated in the Tickloom examples, where the DSL’s grammar is enforced by the host language’s compiler, and the resulting executions are automatically verified.

However, it is imperative to acknowledge that this is not a universally applicable solution. The advantages are most pronounced when the DSL remains sufficiently small and constrained such that a few in-context examples can effectively convey its usage. Furthermore, there is a tangible upfront investment required in designing and maintaining both the language itself and its underlying semantic model. Consequently, the benefits are primarily realized with well-factored, genuinely constrained DSLs that are underpinned by a robust validator.

Illustrative Example: Generating Diagram-Rich PowerPoint Presentations with LLMs

LLMs have significantly simplified the development of custom tooling. In the realm of teaching distributed systems, the frequent need to create presentations replete with diagrams explaining distributed operations within a cluster became apparent. While UML Sequence diagrams are highly effective, presenting a full sequence diagram during an explanation of message flow through a cluster can be less than optimal. This led to the development of a tool designed to display a sequence diagram step-by-step within a PowerPoint presentation. Leveraging LLMs, a tool was created that processes a YAML file describing the presentation structure, including references to PlantUML diagrams, to generate a PowerPoint presentation. The PlantUML diagrams are annotated with explicit steps, and the tool generates a dedicated slide for each step, thereby streamlining the creation of diagram-rich presentations.

Consider a prompt to generate a PlantUML sequence diagram depicting a cluster of three nodes: Athens, Byzantium, and Cyrene. A box would mark the cluster. Actor Alice sends a message labeled "title" with the content "After Dawn" to Athens. Athens then sends a message to itself, with a note indicating the state " After Dawn." Athens subsequently sends a message to Byzantium, which fails. Following this, Athens sends a message to Cyrene, with a note placed to the right of Cyrene. Finally, Athens checks for quorum with isQuorumReached and returns a synchronous "Success" arrow back to Alice. The prompt further specifies the inclusion of a "[step]" marker after each message.

This prompt yields 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:
   After Dawn
end note

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

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

note right of cyrene
  state:
   After Dawn
end note

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

'[step]
athens --> Alice: Success

@enduml

This generated PlantUML code was then utilized to construct a series of slides in a PowerPoint presentation. To facilitate this, a compact YAML specification was developed to delineate the presentation structure and the diagrams to be employed on each slide. This approach enabled the use of LLMs to generate presentations explaining complex distributed systems concepts without the need for manual animation on the slides. An example prompt for generating a slide YAML specification is remarkably straightforward.

For instance, a prompt might request the creation of a slide YAML referring to the diagram ‘quorum-write’ with the title ‘Quorum Write Example’.

This prompt generates a slide specification YAML similar to the following:

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

It is crucial to recognize that even when the prompt specifies the creation of a slide YAML, the output is not arbitrary. Because the tool responsible for generating the PowerPoint presentation and the YAML specification understood by that tool are provided as context within the prompt, the LLM is capable of generating the correct YAML specification that can be directly utilized by the tool. The complete YAML specification can be accessed in a GitHub repository.

In this single example, the LLM effectively performed two distinct roles. Initially, it acted as a co-designer, assisting in the refinement of the step-marked PlantUML extension and the slide YAML built upon existing PlantUML tooling. Subsequently, once this concise DSL was established, the LLM transitioned to its role as a natural-language interface, translating English requests into valid specifications. This division of labor will be revisited later in this discussion.

The Foundation: Building the Semantic Model

The example presented in the previous section was relatively straightforward. The YAML served as a carrier syntax, and its parsed syntax tree was processed directly, effectively utilizing the syntax tree itself as the Semantic Model. This approach, however, couples the syntax to the execution semantics. In more intricate domains, such as distributed systems, more sophisticated semantic models are required to accurately represent the domain’s concepts and the design decisions embedded within the codebase. Let us explore an example based on a small framework developed to rapidly construct and test distributed systems.

Case Study: Tickloom – A Semantic Model for Distributed Systems

Implementing distributed systems, including quorum-based key-value stores or consensus protocols like Raft and Paxos, presents a formidable challenge. Even when implementation is guided incrementally through prompts, specifications, or meticulously constructed Markdown skill files, the asynchronous runtimes still expose an overwhelming spectrum of potential implementation decisions. Threading models, networking patterns, storage coordination, retry behavior, and timing semantics all remain intricately intertwined within the generated code. The complexity extends beyond mere code generation to the equally daunting challenge of verification. The resultant state space, arising from all possible interleavings of thread scheduling, network delays, process pauses, and clock skew, becomes so vast that systematically reviewing and validating correctness across all interacting behaviors is rendered nearly impossible. This inherent difficulty is precisely why tools like Jepsen tests continue to uncover bugs even in the most rigorously tested distributed systems.

This is precisely where a well-defined semantic model proves invaluable. Tickloom is a lightweight framework developed for the construction and testing of distributed algorithms. Its abstractions are not centered around a generic runtime; rather, they embody a set of design decisions governing the behavior of a distributed process. Each node operates within a single-threaded tick loop. Each invocation of tick() advances a logical clock by one unit and processes pending work in a fixed, deterministic order: network, message bus, process, and then storage. Time is measured in ticks, not milliseconds. Messages are represented as plain Java records. Coordination among replicas is expressed through a Replica base class that intrinsically understands concepts like peers, broadcasts, and quorums.

Consequently, threading, timing, and network delivery are no longer open-ended questions to be re-decided with every prompt. What remains for the algorithm author is the core protocol logic. A quorum replica, for instance, is merely a collection of message handlers expressed within the framework’s established vocabulary.

Because the framework supplies the vocabulary – such as Replica, quorumRequest, countResponseIf, MessageType, and Handler – a prompt can remain at the conceptual level of the protocol rather than delving into the intricacies of the underlying plumbing.

For example, a prompt to "Implement a quorum-based key-value store using the Tickloom Replica abstraction. 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." can produce code like the 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 code available at the provided GitHub link)

The semantic model itself acts as a contextual anchor. The prompt references concepts that correspond to concrete types within the codebase, thereby preventing the LLM from inventing a threading model or a networking layer. Instead, it focuses on populating the protocol logic against a stable, well-understood substrate.

The Efficacy of Abstractions, Even Without a Formal DSL

A DSL represents one end of the spectrum, and its creation is not a trivial undertaking. Before embarking on the development of a custom language, it is worthwhile to recognize that a clean set of abstractions already embodies a lighter version of the same principle. Just as the framework supplied the vocabulary for the quorum store example, a library’s named types and methods constitute a vocabulary that the LLM can leverage for grounding. Tickloom’s semantic model is, in essence, a decomposition into four key seams: Process/Replica for computation and message handling, Network for communication, Storage for persistence, and a logical Clock for time. This decomposition accomplishes the majority of the task without introducing any new syntax.

This is precisely why abstractions, not solely DSLs, pair effectively with LLMs. A prompt such as "Implement Raft as a Tickloom Replica" presents a limited state space to explore. The existing QuorumReplica can be incorporated into the context as a worked example.

Developing a DSL for Testing Distributed System Scenarios

Implementing an algorithm is one aspect; rigorously testing its behavior is another. The subtle bugs endemic to distributed systems often manifest in specific temporal orderings: a write that replicates to one node just before a reader’s quorum shifts, a network partition that heals at a critical moment, or two coordinators whose clocks have drifted apart. Constructing such a scenario directly within the test kit requires managing numerous futures and manual tick() loops. Consider the following clock-skew scenario, written in this manner:

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 code available at the provided GitHub link)

The underlying intent – that Bob writes through Byzantium, Alice writes through Athens, and a reader observes Bob’s value because Byzantium’s clock was ahead – is obscured by the procedural mechanics. This code is also challenging to verify; there are dozens of incidental decisions (when to tick, how to encode bytes, which factory overload to call) that an LLM could subtly misinterpret, and a reviewer must scrutinize each one.

Therefore, on top of the semantic model, an internal DSL was developed whose vocabulary mirrors that of the scenario itself: servers, clients, network connections, client actions, and the specific faults active during these operations. The same scenario, when expressed using this DSL, becomes remarkably more concise:

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 code available at the provided GitHub link)

The DSL provides a thin, declarative surface that compiles down to a pure intermediate representation – a Scenario composed of Steps. Each step encapsulates an Action (a read or write) and optional ClusterEvents (faults like partitions and message delays). Faults are also expressed in natural language, for example: partition(BYZANTIUM).from(CYRENE), reconnect(BYZANTIUM), or delay(INTERNAL_SET_REQUEST).from(ATHENS).to(BYZANTIUM, CYRENE).byTicks(100). The grammar is enforced by the type system through progressive interfaces, ensuring that one cannot declare a step before the topology or an action before selecting a client. This prevents entire classes of malformed scenarios from even compiling. Because the DSL is an internal DSL built in Java, the host compiler validates the grammar for free, and any malformed generation results in a compile error precisely at the point of the illegal step, rather than a runtime surprise.

Once the DSL is in place, a natural language description of a failure scenario maps almost directly onto it. A prompt such as:

"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 adheres strictly to 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 code available at the provided GitHub link)

Because the surface area is so small – and the space of valid code that can be generated is significantly smaller than the space of valid Java programs – the LLM has very limited room for hallucination. Furthermore, a reviewer can interpret the output as a description of an experiment rather than as code requiring line-by-line auditing. Even if the LLM were to hallucinate, the internal DSL would prevent compilation, thereby enabling the LLM to correct its errors.

The Two Phases of Working with LLMs

A pattern emerges from the examples discussed: in each instance, the LLM proved useful in two distinct ways.

The first phase involves designing the abstraction or DSL itself. In this capacity, the LLM is best treated as a brainstorming partner rather than a code generator. As argued earlier, the design decisions that constitute a semantic model cannot all be specified upfront; constraints, trade-offs, and edge cases are discovered during implementation. Consequently, this phase is inherently iterative and feedback-driven. One proposes a structure, tests it against a real-world case, identifies awkward areas, and feeds those learnings back into the subsequent iteration. The LLM accelerates this loop by sketching alternatives, critiquing designs, and porting ideas between languages. However, the human remains firmly in control, as these are precisely the decisions that require deep understanding and ownership. The structures that contribute to a pleasant DSL experience, such as progressive interfaces that cause illegal scenarios to fail compilation or semantic models kept separate from their builders, are the kinds of elements that are converged upon through iteration, not by writing a specification and generating code.

The second phase commences once the abstraction or DSL is established. Here, the LLM’s role transforms: it becomes a natural-language interface to the developed system. The prompts presented throughout this article serve as examples – "implement a quorum store as a Tickloom Replica," "write a scenario reproducing the DDIA §10.6 read," or "create a slide YAML for this diagram." In each case, the English description maps almost directly onto the defined vocabulary, and the LLM serves as a dependable generator precisely because the abstraction provides both the contextual grounding for the prompt and the harness for verifying the result.

The DSL as the Definitive Source of Truth

There is a discernible trend towards treating prompts as the primary source of truth. A well-designed DSL fundamentally alters this dynamic. One of the key advantages observed when working with DSLs is that the generated program itself often becomes the artifact that humans maintain. Because a DSL is dense, expressive, and largely devoid of incidental boilerplate, it captures the essential intent of the solution in a form that remains readable long after its generation. If an LLM generates a Tickloom failure scenario from a natural language request, the resulting scenario is already expressed in the domain’s vocabulary. If the scenario requires modification in the future, there is no need to retrieve the original prompt and regenerate everything. The DSL possesses sufficient context for the LLM to understand the intent and work with it. The enduring asset is not the prompt, but the DSL and its underlying semantic model.

Related Articles

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to top button
Lock It Soft
Privacy Overview

This website uses cookies so that we can provide you with the best user experience possible. Cookie information is stored in your browser and performs functions such as recognising you when you return to our website and helping our team to understand which sections of the website you find most interesting and useful.