LLMs and the Craft of Building Abstractions
Unmesh is a Distinguished Engineer at Thoughtworks, based in Pune, India. He is the author of Patterns of Distributed Systems.
Martin is the host of martinfowler.com, the author of Refactoring, and Chief Scientist at Thoughtworks.
A few weeks ago, we exchanged emails about how programmers work with LLMs. We found the conversation stimulating and worth sharing with a wider audience, so we moved it into a file to edit and improve the flow.
Two Kinds of Complexity
Unmesh: I always get annoyed by the claims made about LLMs' role in software development. Re-reading early agile writings, Fred Brooks' classic "No Silver Bullet" helps structure my thoughts.
Martin: When I got into my first job, mentors encouraged me to read The Mythical Man Month. One of its themes was the importance of conceptual integrity:
I believe that large programming projects suffer management problems different in kind from small ones, due to division of labor. I believe the critical need to be the preservation of the conceptual integrity of the product itself. -- Frederick P. Brooks, Jr
The distinction Brooks drew in "No Silver Bullet" between accidental and essential complexity has stayed with me:
Following Aristotle, I divide them into essence, the difficulties inherent in the nature of software, and accidents, those difficulties that today attend its production but are not inherent.... I believe the hard part of building software to be the specification, design, and testing of this conceptual construct, not the labor of representing it and testing the fidelity of the representation. -- Frederick P. Brooks, Jr
Unmesh: This distinction gives clear guidance on where to apply LLMs for simplification. Programming language syntax, framework integration complexity, and boilerplate required just to get software running—that's all accidental complexity. LLMs are good at reducing it. Need a Spring Boot service that talks to Kafka and logs with OpenTelemetry? An LLM can produce a working template in seconds.
But real software work goes beyond making code compile. The act of writing software itself is a complex process. Programming isn't just typing syntax computers can execute; it's shaping a solution. We slice problems into focused pieces, bind related data and behavior together, and crucially, choose names that expose intent. Good names turn code into a schematic everyone can follow.
No one sees the whole design on day one. Progress comes from a rhythm: think a bit, write a bit, step back, refine. Each iteration sharpens both the code and your understanding of the bigger picture. Writing code is often where design decisions crystallize.
Impossibility results keep us grounded. I think of "upfront design" in terms of an impossibility result I call 'Upfront Specification Impossibility' (USI). It sets a practical limit: the first spec is at best a hypothesis. Waterfall fought USI; Agile works with it.
Growing a Language
Martin: The idea that design and architecture is evolutionary is core to agile methods. What you've said about weaving names brings to mind the Domain-Driven Design notion of Ubiquitous Language—our interaction with the LLM builds that language, iteratively.
Unmesh: Or better than saying we build a language, we grow one—following Guy Steele's 1998 OOPSLA keynote title:
My point is that a good programmer in these times does not just write programs. A good programmer builds a working vocabulary. In other words, a good programmer does language design, though not from scratch, but by building on the frame of a base language. -- Guy Steele
Martin: That's the best technical talk I've ever seen; I was lucky enough to be in the audience. Lispers often described molding their environment into a language for their domain—a mindset I try to follow.
Unmesh: Programming is the act of growing a language. Where do words for abstractions come from? Some come from established domain concepts; some become commonplace in a programming community—"I need a Repository to access stored data," "I need a Controller to handle user requests." Long-lived languages like Ruby, Smalltalk, Lisp, Java, and now Python establish vibrant communities, and paradigms shape our thinking. When building data pipelines, the functional paradigm helps me think about transformation chains over data streams. For interactive or web applications, the object-oriented paradigm helps me think in terms of objects encapsulating state and behavior.
As we write code, we also learn the structure of the solution. There are many micro-decisions made in flow: Should this be a separate class? What's a good name for this function? Should I restructure this class hierarchy? There are two parts: taking the decision, then coding to execute it. LLMs excel at generating or changing code for specific decisions. But arriving at those decisions requires back-and-forth through code. What looks like "wasting time" is actually shaping the mental model of a solution's structure in a given language. Once that mental model is clear, it's easier to give specific instructions to LLMs. This process solidifies abstractions—the essence of forming vocabulary.
When I hear "drive everything through prompts," I sense upfront design in the making—that's dangerous. Without iterating with code, you must speculate about design structure, leaving abstraction decisions to the LLM. Reviews done after decisions are codified aren't as effective—the same reason we prefer pair programming over passive reviews.
Martin: Raising the difference between pair programming and code reviews is interesting. Though much of the industry prefers pull requests and pre-integration reviews, we're in the smaller camp that vastly prefers pairing. Pairing involves higher-frequency interaction and fluid role play—you may review my code now, but in ten minutes, I review yours. Pairing is constant discussion with code growing out of it. So it's reasonable that someone used to pull requests will interact with an LLM differently than someone used to pairing. Those of us with a pairing background naturally think of back-and-forth with the machine, growing ideas in tandem.
Two Activities of Programming
Unmesh: How we think about LLMs depends on the programming activity. I think of programming as two deeply interwoven activities.
The first is Discovering and Stabilizing Abstractions—creative work where we explore options, experiment, and converge on stable abstractions. We observe coding patterns, decide what's common, and identify what might vary. We then choose language constructs that arrange common elements to ease future variation.
Once abstractions are stable—well-named, well-tested, understood—we Apply Stable Abstractions to implement new use cases. In Domain-Driven Design terms, this is working with the domain model.
Martin: We call both programming, but because they're different activities, we work with an LLM differently. With stable abstractions, generating code on top of them is more mechanical. We've defined the language; the LLM knows how to generate code with it.
Unmesh: Exactly—with a concrete vocabulary, LLMs don't guess what terms mean, making the result largely boilerplate code generated reliably. They shine in other mechanical parts of development:
- Setting up projects and iteration-0 tasks
- Creating build pipelines and CI/CD setups
- Repeating established coding patterns with minor variations
LLMs are of great help during abstraction discovery too. When starting to code, it's useful to see alternatives quickly. LLMs turn intent into code fast—say what you want in plain English, get a small snippet. Seeing code quickly helps test names and boundaries, compare options, and shape abstractions. In this case, I prefer chat mode over agent mode, allowing exploration without corrupting existing implementations.
Another technique I use is language projections: rewriting the same code in another language (Go, C++, etc.) to test ideas and implementation alternatives quickly. You don't need to type that code yourself, and you can get it even in languages you don't know. I find this far more useful than crafting elaborate prompts. Free-form conversation while exploring code-level abstractions is more productive.
Martin: This reminds me of how I was told off in my pre-university job in the 1980s for not triple-checking code before compiling on our mini-computer. The "right way" was getting a page of Fortran to compile "first-time." But I found myself faster going a few cycles with the compiler. Once the feedback loop sped up, iteration beat pre-submission checking. As my experience grew, I increased my interaction with the computer to explore abstractions. Writing code, I pay attention to how existing abstractions ease expressing the behavior I want. If I find something hard to enunciate in the language my abstractions created, that suggests refinement needs. The computer keeps me honest—without running the compiler and executing tests, it's too easy to fool myself. The computer forces me to confront my abstractions' limitations.
Unmesh: Crucially, we cannot fully specify upfront what these abstractions will look like, nor which language features will best express them. The shape emerges only while working through code. Developers see patterns and gradually refactor toward a stable structure.
Martin: LLMs seem a natural further step in partnership with the computer, providing a new and rich mechanism for feedback on vague ideas.
Unmesh: This process cannot be reduced to a static prompt. Reviewing LLM-generated code is rarely enough—you miss deep thinking that happens when coding yourself. At this stage, LLMs are brainstorming partners that suggest alternative designs, but you should resist letting them generate core implementations.
Domain and Solution Intersect
Richard Gabriel defines abstractions as compression—encoding well-established meaning into a compact form, much like words stand for larger concepts. When we speak of a domain model, we often think only of the functional or problem domain—Finance, Retail, Healthcare. But functional knowledge alone rarely forms truly useful abstractions. Another domain is always at play: the solution domain.
The solution domain is shaped by architectural and technological choices—whether we build web-based or event-driven systems; whether we rely on in-memory data grids or relational databases; whether integrations are synchronous APIs or asynchronous messaging. These choices shape our abstractions. In most projects, the domain model's vocabulary emerges at the intersection of the functional domain and the solution domain. Domain experts with narrow functional focus cannot work in isolation. Successful teams bring together deep functional and technical expertise to shape a shared language.
The Agile community has long recognized that good vocabulary emerges from collaboration. LLMs have not changed how we build solutions, form domain models, or create vocabulary—it still requires deliberate effort. In fact, failing to create a shared domain vocabulary has even greater costs with LLMs: without concrete reference points, the model must guess at meaning, often producing results that miss the mark.



