Design that matters: what a Selenium SDK taught me about my AI SDK
I've studied software architecture; taken the courses, drawn the diagrams, and read the books on modeling and design. None of that is new to me. What is new is that I only recently really applied it to my own projects.
I maintain a set of ecosystem libraries for the D programming language. There are around seven of them now, each fairly large and technical, and I keep filling them out. The one that forced this lesson on me is Intuit, my AI SDK. It is something like LangChain for D: completions, structured output, context management, streaming, embeddings, and native D functions as tools, all behind a small set of provider endpoints.
More than six months of total development have gone into it, with a lot of genuine thought about the design. But it was designed bottom-up, and that is the part that quietly cost me.
The blocker
I was building toward a v2. The goals were concrete: new providers, an overhauled test suite, better context management, and, most importantly, routers. A LocalRouter for people running models locally, and an OpenRouter integration. Routers matter because most users do not want to hand-wire endpoints and models. They want model details, a single centralized and compactable context, and an active model they can select and swap. A router was supposed to be the friendly front door.
That is where I hit the wall, and the wall was a design flaw, not a bug.
Issue 1: models that floated between layers
I had built the entire library around an endpoint-and-model system, and over time it became ambiguous what each one was actually for. If you knew the codebase, you knew that a model primarily served as a configuration interface: temperature, max tokens, response schema, tool choice. But the moment I started adding layers, an implicit low-level and high-level split appeared, and the model concept did not belong cleanly to either. It started being a weird layer that isn't clearly low or high level and makes designing everything else significantly harder.
The biggest point of contention was IModel. Every provider implemented it: OpenAIModel, ClaudeModel, QwenModel. Each of those classes carried the configuration fields and the protocol logic, building request payloads and parsing responses:
interface IModel
{
ref string name();
ref string owner();
JSONValue completionsJSON(JSONValue input, ToolRegistry tools = ToolRegistry.init);
JSONValue embeddingsJSON(JSONValue input);
Completion parseCompletions(JSONValue response);
JSONValue parseEmbeddings(JSONValue response);
}
Two problems fell out of this. First, it was horrible for reuse: the message parsing, the finish-reason mapping, the payload assembly were nearly identical across providers and got copied into each model. Second, and worse, IModel mirrored IEndpoint's convention so closely, both interfaces, both with ref string name(), both stringly similar, that it was genuinely confusing which one owned what. A model held configuration, but it also did the wire work an endpoint should own. The two interface hierarchies looked like peers when they were not.
Issue 2: no defined way to manage endpoints and models
The second flaw was a direct consequence of the first. Because the model concept was ambiguous, I had never well-defined how a user is supposed to manage their endpoints and models together. The provider classes for OpenAI and Claude were good, genuinely good, but the low-level interface they exposed did not lend itself to extension. Any change I wanted to make as scope grew was likely to be breaking, because the contract users leaned on was the accidental shape of the implementation rather than a deliberate boundary.
This is the bottom-up tax. When you build upward from working pieces, every piece is locally correct, but the seams between them are never designed. The issues only surface when the project expands in scope, exactly when they are most expensive to fix.
The contrast: a Selenium SDK and W3C
Around the same time I was reworking another library, Selenium, a D library for browser automation over the WebDriver protocol. Implementing real browsers, Chrome, Firefox, Edge, Safari, and staying compliant with W3C WebDriver while keeping the API idiomatic for D turned out to be the thing that fixed my thinking.
The difference is that Selenium has a contract handed to it from the outside. The W3C specification already decides what the layers are. It tells you what the wire protocol is, what a session is, what a command is, and what a capability is. You cannot blur those layers even if you want to, because the spec defines them. That external authority did for Selenium exactly what I had failed to do for Intuit: it forced clean separation.
When you look at the result, the layering is obvious:
Bridgeowns transport: process lifecycle, session creation, HTTP plumbing, error mapping. Low level.Driverowns session commands: navigate, find elements, execute scripts, manage windows and frames.Browserowns configuration: capabilities, timeouts, and the per-browser subclasses for provider-specific options.
Browser is a single, honest configuration type. It never builds a request or parses a response. Bridge never decides what a timeout means. Each layer has one job because the contract refused to let them share.
Carrying it back to Intuit
Once I saw it that way, the Intuit rework wrote itself. I mapped Selenium's layers onto it:
IEndpointbecomes the transport-and-provider layer, the analog ofBridge. It owns the quirks of talking to OpenAI or Claude.ModelConfig, a single concrete class, becomes the configuration layer, the analog ofBrowser. I deletedIModelentirely and made it way easier to reuse parsing. One type, one place.ModelConfigstill handles parsing for payloads and responses, but is significantly easier to understand.- Routers and the request functions become the orchestration layer, the analog of
Driver, operating over a maintained context and an active model.
The endpoint now simply hands you a config:
ModelConfig config(string modelName);
The model stopped floating. It is unambiguously the configuration layer now, and the duplication is gone with it.
The part that genuinely surprised me is how non-breaking this was. The path I had been recommending all along was calling by model name, completions(ep, "gpt-4o", ctx), and that call site did not change at all. Swapping IModel for ModelConfig happened underneath it, and the routers layered cleanly on top of the now-clear boundary. A rework that I expected to be invasive turned out to be mostly internal, precisely because the new design had real layers to hang things on.
The actual lesson
I did not learn anything I had not already been taught. I learned to use it. Design in advance matters even for personal projects, and especially for libraries that other people will build on.
You do not always need a specification up front, but you do need to decide your layers and your contracts before scope expands, because bottom-up code never designs its own seams. And when a proven external contract already exists, like W3C WebDriver, borrow it. It will impose the discipline you would otherwise have to invent, and it will quietly stop you from building the exact ambiguity I built into Intuit's first design.
If you maintain your own projects, this is the part worth internalizing: the courses click when you finally let a real blocker force the issue. Better to design the seam now than to rediscover it the day your scope doubles.