williamcotton.com

The Evolution of a DSL

January 15th, 2026

Over the last several years I've been experimenting with small domain-specific languages for, amongst other things, building web applications. These projects were never intended to replace existing frameworks or tools for other developers but rather a way to explore ideas that felt awkward or overly indirect to express in the systems I was using at the time. Most importantly, I made them for myself in a manner that enhanced my daily workflow.

One of the earlier experiments was a language and runtime called WebDSL. The goal was to encode a complete web application stack directly into the language. Concepts like websites, pages, API endpoints, authentication, sessions, database access, and migrations were all represented explicitly. This reduced a large amount of boilerplate and made it possible to build small applications with very little surrounding infrastructure.

A minimal example in that system looked like this:

website {
    port 3123

    page {
        route "/hello/:world"
        pipeline {
            jq { { world: .params.world } }
        }
        mustache {
            <p>hello, {{world}}</p>
        }
    }
}

This defined a website, a page, a route, and a request pipeline. The structure was intentionally declarative and, for a time, felt like a reasonable way to organize a web application. I built a few demo projects with it and learned a great deal about what kinds of assumptions I was willing to bake into a runtime (which is quite a lot, to be honest!).

As the system grew, more behavior began to migrate into embedded scripting. Lua and jq were doing increasingly important work, and pipelines started to feel like an implementation detail rather than the core abstraction. The same pipeline construct could behave differently depending on whether it appeared inside a page, an API endpoint, or some other context. Explaining those differences required documentation rather than code, which was usually a sign that the abstraction boundary was in the wrong place.

The next iteration came from removing most of that structure and focusing on the part that consistently held up. In the newer system, Web Pipe, the pipeline itself is the primary unit of execution. An HTTP request is parsed into a JSON representation and passed through a sequence of transformations. Routing, database access, templating, GraphQL calls, logging, and other concerns are all expressed as pipeline stages rather than as separate subsystems.

The same “hello world” example becomes:

GET /hello/:world
  |> jq: `{ world: .params.world }`
  |> handlebars: `<p>hello, {{world}}</p>`

There is no enclosing website block and no page abstraction. The route and the pipeline are the same thing.

One consequence of this change was that tests no longer needed a separate framework or lifecycle. If a request is just a pipeline, then a test is simply a way to execute that pipeline with controlled inputs and assert on the output. In Web Pipe, tests live in the same file and use the same execution semantics as the application itself.

describe "hello, world"
  it "calls the route"
    let world = "world"

    when calling GET /hello/{{world}}
    then status is 200
    and selector `p` text equals "hello, {{world}}"

This wasn't an explicit goal of the redesign but a side effect of having only one way for requests to execute. I was particularly inspired by some early feedback on HackerNews about adding "a bit more discussion of how we'd test our webpipe code". So thank you, random internet stranger!

The most significant change between the two systems wasn't syntactic rather a shift in what the language committed to early. WebDSL optimized for completeness and convenience where Web Pipe optimizes for having as few execution rules as possible.

Web Pipe is intentionally incomplete as there are plenty of missing features and sharp edges. This is acceptable! The point of the exercise isn't to finish the system but to keep the execution model small enough that changes remain understandable and easy to extend.