Keeping LLM Code Generation Grounded With A Specification-First Approach
A specification-first approach keeps LLM code generation grounded by making design decisions explicit before implementation, so the model implements the system instead of quietly inventing it.
I started writing specifications before writing code. Not because I had a formal methodology in mind, but because it was the most natural way to think through a system before asking a model to implement it.
After using this workflow across several projects, I have a much clearer sense of why that initial instinct was the right one. The specifications shaped generation in ways that no single prompt could have done reliably on its own. They kept important design decisions explicit instead of letting them collapse into a generic solution. They also became a recoverable project layer across sessions, so that important context did not have to be reconstructed from the codebase each time.
This article is about what that looked like in practice: what kinds of specifications I wrote, how they were structured, and how they shaped the project.
The Real Problem Is Hidden Design Decisions
The hardest part of LLM code generation is usually not syntax or boilerplate.
The main difficulty is that a non-trivial codebase contains many decisions that are easy to leave implicit: where one module ends and another begins, which interfaces are stable, which implementation details are actually architectural constraints, and what belongs to the current phase versus a later one.
If those decisions are not made explicitly in a project artifact, the model will still make them during generation. The result can look clean in isolation while still being wrong at the system level.
That is what makes this kind of drift difficult. The generated code can look locally reasonable while the larger structure quietly degrades: boundaries drift, responsibilities blur, stable patterns stop being stable, and phase boundaries dissolve.
What Was Actually Specified
The specification layer follows the high-level split of the system into components. In the runtime specification tree, for example, the documents are already organized around major runtime surfaces. A specification folder often corresponds to one larger component. Inside that folder, one spec file often, though not always, corresponds to one module.
That keeps the specification layer structurally close to the architecture of the project. It is not one large design document and not a loose collection of notes. It is a working surface that mirrors the system at a useful level of granularity.
In practice, one specification often described:
- the purpose and boundary of the module;
- what the module does and what it explicitly does not do;
- the public interface;
- input and output types;
- which types are owned locally and which come from a shared layer;
- how output types are populated;
- the step-by-step algorithm of the module;
- validation rules, invariants, and constraints;
- the module’s error boundary;
- dependencies on other modules;
- external service APIs when the module called them;
- how requests to those external services are constructed;
- how responses are parsed and converted into internal typed structures.
That meant the specification did not stop at “what feature this module implements.” It fixed the whole contract surface of the module: interface, behavior, owned rules, limits, dependencies, and boundaries.
Other specification documents play a different role. They are not written for one module, but for one specific aspect of the system that needs to be defined in one place. For example, one specification defines the unit-test surface for all modules inside a component: which tests have to be generated for which module. The same pattern appears in specifications that define the tracing surface across modules, and in generation-oriented specifications that fix which files have to be generated and how they are organized in the project hierarchy. Together with the module specifications, these documents help describe the system as a whole.
Specs As Constraint Systems
Many of the constraints in these specs would look redundant in ordinary project documentation. In practice, they are often the part that matters for generation.
If I leave a gap, the model does not leave it empty. It fills it with a pattern that is common in the code it has seen before.
That is why the negative constraints matter so much. A section like This module does not is not documentation noise. It is a direct constraint on generation. For example, a module spec explicitly lists the tasks a module must not perform because they belong to the responsibility zone of other modules. Without those exclusions, it is much easier for the generated implementation to absorb extra responsibilities simply because that looks locally reasonable.
The same is true for scope. When something is intentionally postponed, I have to say so explicitly. Otherwise the model has no reason to treat the missing part as intentional. It can just as easily interpret it as something that should be completed now.
This is why these specs are constraint systems. Their job is not only to explain the module. Their job is to narrow the space of plausible implementations until the generator has much less room to invent its own architecture.
Different Risks, Different Specs
Not all specifications in the workflow address the same kinds of risk.
The first group consists of specifications for modules. In those documents, the main focus is on behavior: input and output types, how outputs are populated, the step-by-step algorithm, validation rules, invariants, error boundaries, and explicit non-goals. For this kind of module, the main risk during generation is local drift. The model can simplify an algorithm, weaken validation, broaden the behavior, or start taking on a neighboring task because that looks locally reasonable.
The second group consists of specifications for control and integration components, for example the orchestration layer and external service clients. Here the center of gravity shifts. These specs are much more focused on exact public interfaces, exact public types, ownership boundaries, lifecycle rules, configuration entry points, request construction, response validation, and type conversion across the boundary. In these layers, the main risk is no longer just local behavior drift. It is architectural drift. A small interface change or a blurred boundary can change the shape of the system itself.
This is the main difference between the two groups. For module specifications, the main job is to discipline behavior. For control and integration components, it is to discipline interfaces and ownership. That is why these specs do not all look the same. Their form follows the kind of mistake that is most dangerous in that part of the system.
Specs That Shape Code Generation
Some specifications in this workflow do not describe the behavior of one module at all. They shape the code-generation process itself.
Part of that job is structural. Before generating leaf modules, the runtime already has a defined project structure, module tree, and file layout. That structural frame is itself described in the runtime specification. Generation does not start from an empty space. It starts inside a predeclared structural frame that says which parts belong to the crate level, which parts are delegated to child specifications, and where different pieces of the system are supposed to live.
Another part is deciding which modules have to be generated and which specification is responsible for each of them. That is fixed explicitly in the generated artifacts specification. That makes the generation process much more explicit. Instead of asking the model to infer what the output set should be, the specification layer already says which project files must exist and which document owns each generated surface.
A third part is generation order. In the generation-order specification for one of the API-client slices, that order is made dependency-aware explicitly. Shared types come before the modules that use them. Lower-level clients come before higher-level layers built on top of them. Test generation comes after the corresponding runtime modules already exist. Without that ordering, the model is much more likely to introduce temporary local types, duplicate structures, or other artifacts that only exist because generation started from the wrong place.
This is another place where the specification layer does more than describe the system. It controls not only the produced code, but also how code generation is organized.
Specs That Define Verification And Inspection Surfaces
The specification layer does not stop at behavior and code generation. It also defines the surfaces through which the system is later verified and inspected.
One part of that work is test generation. The test specifications define which tests have to be generated for which modules, where those tests belong, and what counts as a proper generated test. That matters because test generation cannot be left to the model either. Without an explicit contract, it is too easy to get tests that exist formally but do not verify much in practice.
Another part of that work is observability. The observability specifications define the tracing surface across modules: which spans a module has to emit, how those spans fit into a larger hierarchy, how standard runtime tracing and OpenInference tracing are separated, and which telemetry constraints have to hold. In a multi-stage LLM system, that is part of the system design, not just something added later for debugging.
The specification layer describes not only how the system works and how its code is generated, but also how that system is verified and how it remains visible once it is running.
How Early Decisions Become Specifications
In some projects, the specification layer does not appear in its final form immediately. Part of it grows out of more temporary artifacts.
That was especially true in one of the more complex projects. At the beginning, I used one decision log to hold scope, early decisions, and open questions in one place. At that stage, the project was still too fluid to justify splitting every emerging decision into separate documents. The log was not meant to remain the final structure. It was a temporary layer that helped the project stay coherent until more specific specifications could grow out of it.
Another important source of later specifications is manual experimentation. Early manual experiment protocols and manual experiment sets are not just auxiliary notes. They probe the workflow itself: what a good first response should contain, what failure modes are already visible, and which parts of the prompt and response shape still need adjustment. When an observation from those runs becomes stable enough, it can move into the specification layer and only then into code. Part of the workflow is experiment -> observation -> spec -> code.
This matters because not every useful project decision starts as a stable specification. Sometimes it starts as a temporary decision or as an observed pattern in manual runs. The specification layer becomes the place where those temporary findings stop being provisional and start shaping the system in a durable way.
Where This Approach Pays Off, And Where It Does Not
This approach pays off most clearly in systems where leaving decisions implicit is expensive: systems with many modules, many boundaries, durable state, shared contracts, or orchestration logic that can drift if interfaces are even slightly underspecified. In that kind of project, the specification layer does real engineering work. It keeps decisions explicit, keeps module boundaries sharper, and makes generation more systematic.
The cost is real as well. Specifications have to be written, maintained, and kept aligned with the code. When they become outdated or remain incomplete in the wrong places, the workflow starts to slide back toward model-driven decision making. In smaller or more disposable projects, that cost may not be justified. A short-lived script or a throwaway prototype often does not need this much structure.
For me, the main value of specification-first is not better documentation for its own sake. It is a way to make sure that the important project decisions are made explicitly before the model starts to implement them. That shifts more of the design work back to the engineer and makes the resulting system more stable as it grows.
It is about refusing to let the model become the default architect of the system.
Supporting Documents
- Specification/runtime — the main runtime specification tree, organized around the major runtime surfaces.
- Specification/runtime/orchestrator — orchestration-layer specifications with a strong focus on interfaces, ownership, and boundaries.
- Specification/runtime/api_clients/qdrant — external-service client specifications that show how transport and integration boundaries are fixed explicitly.
- Specification/runtime/runtime.md — the structural runtime specification that defines the overall project and module layout for the runtime.
- Specification/runtime/generated_artifacts.md — the artifact-level specification that says which generated files must exist and which specification owns them.
- Specification/runtime/api_clients/qdrant/generation_order.md — an example of a dependency-aware generation-order specification.
- Specification/runtime/unit_tests.md — the test specification that defines the expected verification surface for generated modules.
- Specification/runtime/observability — observability specifications that define tracing structure, span ownership, and telemetry constraints.