Let software emerge: how I think about architecture
± 16 min read Published
Executive summary
- Idea. Structure and flow need each other. A boundary is not there to restrict, it is there to make room inside it.
- How. Four steps: play, build, rest, refactor. The last one, paying off the debt taken on while playing and building, is the one that usually gets skipped, so here it is a fixed step.
- Emerge. Do not abstract too fast. Duplicate a few times inside a context, then see what is really generic and deduplicate.
- Architecture. Domain-Driven Design: each capability in its own boundary, dependencies that run one way and are guarded by a tool, and the decisions behind that written down as short records.
- Contracts. Capabilities talk through contracts owned by the one that asks, each with a null answer, so a capability can be switched on or off without breaking its neighbours.
- Transparency. Perfection is an over-constraint. Name the shortcut, put it on a list and pay it off as a step of its own.
- Feedback. Show things early, and make releasing calm enough to do it often, so feedback can come in often.
- Who builds it. The same shape around the builders: named roles, explicit mandates and defined handovers, for people and just as much for an organisation of AI agents.
This is how I think about building software: the way of working and the architecture behind it. The examples below are fictional, from a small webshop. How I applied all of this to a real app is told in the Pack a Thing article.
A river needs a bed
Structure and flow need each other. Too much structure and the flow stagnates; too little and there is no direction, so the flow stops as well. A river needs a bed, and a bed without a river is a ditch.
That is a statement about software as much as about a day. A boundary in a codebase is not there to restrict anyone, it is there to create room inside it: if the edges of a part are clear, you can move freely within them without having to hold the whole system in your head. Most of the decisions further down come from that one idea.
I write about that balance between structure and freedom in its own right, in Dutch, on czyk.nl/flow.
Play, build, rest, refactor
In software development we build straight for production far too often. The thing ships, it is legacy the same week, debt has been added, and we move on to the next thing immediately. That is how the debt grows.
My way of working is different, in four steps.
Play first. Allow yourself to play before anything has to be good or finished. It can even be fun and joyful, because there is no pressure yet to get it done and to do it right. That holds for software, and just as much for a hands-on project where you have a new discipline to learn. During playtime it is far easier to throw something away and start over, because nothing depends on it yet.
Then build. Reflect on the play, make a plan, build it and take it to production. The knowledge and the information are already there; you only have to carry it out. That is what makes this step easy instead of heroic.
It does not have to be production, as long as it is genuinely in use. A test environment counts, provided people actually work in it, and so does a feature toggle that opens the thing to one group first. Use is the point: without it nothing comes back, and the next step has nothing to look at.
Let it rest. Gather data, watch how it is actually used, write the insights down, and keep an eye on the error logs as they come in, so a bug gets fixed before anyone has to report it. Not a pause — the step that tells you what the next one should be.
Rest matters for another reason too: it puts you in a different mindset. While you build you are on your way to a goal, fixing problems, fixated on the target. When you let it rest, room appears and your view widens. From that place you can reflect better and see patterns, or see them again.
Then refactor. Rewrite or adjust the thing that is already in production, so the shortcuts and the debt you took on get resolved. This is the step that gets skipped most of the time, and it is the important one. It is how you end up with stable, reliable, reusable parts: debt goes down, modularity goes up, and later you can build on what is there instead of having to refactor it first, with all the risk that carries as complexity grows.
Let software emerge
You can design software up front, but that is often harder than it looks, because you do not know reality well enough yet. You do not have the insight or the feedback. I found it much easier to let software emerge: try things, put them live, and then look at how they are used, look at the data, look at where duplication appears.
That means allowing yourself not to abstract too fast. Duplicate, inside the context you are working in. After a few duplications it is fairly easy to see what is really generic, what is worth abstracting, and what is still specific and should stay inside its bounded context. It touches the principle of “don’t repeat yourself”, but I always say: do repeat yourself. At least a few times.
From there you can lean back and look at which piece you actually want to reduce. Those are always nice changes, because you built the work without having to think about the perfect generic model. You get speed, you may duplicate a little, and afterwards you deduplicate into the piece that is generic and highly reusable.
Domain-Driven Design keeps it readable
The architecture follows Domain-Driven Design, so that the concepts and the processes in the code are readable and understandable by a human being. An order is an order, a stock item is a stock item, and ordering asks stock for something rather than reaching into it.
The seam between two parts is the clearest example. In a webshop, ordering holds what a customer wants, and shipping is nothing but parcels on a day. So ordering does not reach across and write a delivery date, it asks for the order to be shipped. Shipping does not know about the checkout: the order asks, shipping answers. If the shop switches shipping off, say it only offers pickup, the order simply stays a plain order, without a date. Nothing breaks, because the one that asks never depended on the answer. That is not an implementation detail, it is a decision about who knows what.
Each capability lives in its own directory with its own README and its own descriptor, and carries a subdomain type — core, supporting or generic — so it is explicit which parts deserve the care and which parts merely have to work. Dependencies between capabilities are guarded by a tool rather than remembered by anyone's memory, and the decisions that set those rules are written down as short architecture decision records: directories follow boundaries before types, the asker owns the contract, the centre discovers, never enumerates.
Circular dependencies get a rule of their own, because two capabilities that depend on each other are not two capabilities: they are one system wearing two names. An ADR forbids them, and an application architect stands guard over that line. That does not make a violation a disaster. While playing and while building they are allowed to appear, because stopping to be correct is exactly what kills play. They come out afterwards, in the refactoring step, through a process that goes over the architecture and refactors it back into line with the modular design. The same four steps, applied to the shape of the system itself.
The same boundaries can be visible from outside the code, too. If a product offers an API or tools for assistants, group them the way the capabilities are grouped: one surface per capability, so what is separate inside stays separate outside.
Most of the philosophy behind that architecture comes from The Anatomy of Domain-Driven Design by Scott Millett and Samuel Knight, which in turn builds on Eric Evans' Domain-Driven Design. Clean Code, The Clean Coder and Clean Architecture by Robert C. Martin shaped it as well, and so did Design Patterns by Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides. The refactor step comes from Refactoring by Martin Fowler.
Capabilities that switch on and off
A capability can be turned on or off without anything around it breaking. Which capability a customer is offered can follow from a plan, a region or the stage a feature is in: in a webshop, think of gift wrapping or loyalty points. That works because of how the capabilities talk to each other, and it is the opposite of a big ball of mud.
Capabilities do not reach into each other. When one needs something from another, it declares a contract: the capability that asks the question owns the interface. The checkout wants to know whether an item is in stock, so Checkout owns that question and Stock answers it. The arrow therefore points from the answerer to the asker, which feels backwards until you see what it buys: the asker never names its answerer, and ships a null answer of its own. If nobody answers, Checkout still renders and still runs, and simply shows less. There is no check for whether something exists, and no flag woven through the code.
Functionality is enriched by adding an answerer, not by editing the thing that asks. Reporting collects numbers from the capabilities in exactly the same way: it owns the question, and each capability answers what only it can count. A new capability joins by implementing contracts that already exist, and switching one off leaves the others standing.
It has a price, and I would rather name it. Arrows often point against intuition and against the direction the data flows, so a diagram alone cannot tell this pattern from a real breach. And every contract costs an interface, a null answer and their tests. That is only worth it where a boundary is real. Where it turns out not to be, the answer is to merge the two and drop the machinery.
A good test of the idea is a nightly job that deletes what has expired, in a shop the abandoned baskets and the old delivery addresses. The job does not know what is kept. Each capability registers its own retention policy, saying how long it keeps its rows and what “gone” means for them, and the job only walks the list. The centre decides when, the owner decides what, and a new capability joins by registering a policy instead of being added to the job.
The same goes for the rules that matter most. If a rule only holds because every developer remembers it, it will be broken sooner or later. In a webshop, keep customer addresses in a store of their own, reachable only through one front door that logs and audits every read. Then logging is not a convention, it is a property of the only way in: there is no route around it, not even from the shop’s own code. Make the right thing the only possible thing, and the rule no longer depends on anyone’s memory.
That is what makes the capabilities independent: each one can be used and taken on its own, and together they still make one product.
Transparency over perfection
The same philosophy has a consequence that is easy to get wrong: do not strive for perfection, because perfection is an over-constraint. Say a webshop wants to promise in its terms that “100% of the data stays inside the EU”. That makes it very hard for itself, and the shop would stagnate on every vendor that does not qualify. Or, worse, the sentence would quietly stop being true.
So say what is true, and be transparent about it, in the terms and in the product. Transparency is more important than perfection, which is stagnation, or than misinformation, which is worse.
It is not a licence, though, and the limit is worth naming. Some shortcuts can be paid off completely later: a manual step, a hard-coded list, a screen that does not exist yet. Others cannot. Data that has been handed to a vendor stays handed over: swapping that vendor for another later does not take it back, and it can still leak from where it already is. For a shortcut like that, transparency is not enough on its own. Choose carefully before the first byte goes out, and send as little as you can.
That is also why such a vendor should sit behind a contract to the outside world, modelled the same way as the contracts between capabilities. The shop asks the question, the vendor is one answerer, and swapping it for another does not mean refactoring any internal code. The seam makes the next swap cheap and keeps what is shared small. It does not undo the past.
Seen through the four steps, it looks like this. A shortcut is something you knowingly take on in step two and three, while playing and building, and name explicitly. By making it explicit and visible, step four, refactoring, gets done as well. If it stays implicit it gets forgotten and lost, until it shows up as a data leak and a big oops. This way the debt is on a list, with a name next to it, and it is paid back on purpose.
Early feedback and releasing without fear
Two more things belong to the same philosophy. The first is early feedback. An idea is cheapest to change while it is still early, so I would rather show it to the people it is for before it is finished than polish it alone. A vote or a remark on something half-built tells you more than your own guess about it.
The second is releasing without fear. Fear of a release is a sign that the way to production is not trustworthy, and fear makes people release rarely, in big steps, which is exactly what makes a release dangerous. So the way there gets the same care as the code: small changes, a check at every stop, changes that only add before they take away, and a release that can be undone. When going live is a calm, boring act, you can do it often, and feedback can come in often.
The same shape around who builds it
The idea does not stop at the code. In any organisation that builds software, roles and teams are named, each role has a mandate and, just as importantly, explicit restrictions, and the moments where work moves from one role to the next are defined handovers rather than a shrug. The structure around the builders is a river bed too: without it the work does not go faster, it goes everywhere, because somebody without a boundary will cheerfully redesign something nobody asked about.
That is a statement about the real world, about people and teams. It also carries over. If you set up an organisation of AI agents, it makes sense to model the same roles and mandates you would use for people, for the same reasons. The agents do not need a different kind of structure, they need the one that already works. How that looks in practice, with an org chart of AI roles, is part of the Pack a Thing article.
Structure that makes room
None of this is meant to be impressive on a diagram. The boundaries are there so that the next change is easy, the contracts so that a part can be switched off without a fuss, and the four steps so that the debt gets paid instead of inherited. A river needs a bed, and then it is the river you came for.
The product that all this was applied to is told in the Pack a Thing article.
Books that shaped this
Not sources I looked things up in for this article. These are the books that inspired the way I came to think about it, over the years. Each one links to its product page on czyk.nl.
- The Anatomy of Domain-Driven Design (booklet) Scott Millett and Samuel Knight
- Domain-Driven Design: Tackling Complexity in the Heart of Software Eric Evans
- Design Patterns Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides
- Clean Code Robert C. Martin
- The Clean Coder Robert C. Martin
- Clean Architecture Robert C. Martin
- Refactoring Martin Fowler