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. They were 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.
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. It was 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. It was a shift in what the language committed to early. WebDSL optimized for completeness and convenience. Web Pipe optimizes for having as few execution rules as possible. The earlier system made it easy to get started but harder to explain precisely what was happening. The later system makes fewer promises and exposes more of its behavior directly.
Web Pipe is intentionally incomplete. There are missing features, sharp edges, and unresolved questions about semantics. 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.
Looking back, the most useful part of this process wasn’t building something that worked. It was building something that was large enough to show me what didn’t need to be there. I doubt this is the last iteration.