Most architecture documents cannot be wrong. They say things like services contain business logic and repositories handle data access, which sound like rules but are really just vocabulary. You cannot fail them. You cannot even check them. A year later the codebase has drifted somewhere unrecognizable and the document is still technically accurate, because it never said anything falsifiable in the first place.
So when we wrote ours, we tried to put a test in it. Then I went and ran that test against the codebase by hand, and this post is what came back. The short version: we fail it, the failure is the opposite shape from the one I expected, and the reason is more interesting than people were sloppy.
01Four layers and a rule
The contract is small enough to hold in your head, which is most of why it is worth having. Four layers, and for each one, a list of things it is not allowed to do. The prohibitions matter more than the permissions.
The contract
- →Repositories own every database call. They may not contain business logic, call a model, or know that HTTP exists.
- →Services own business logic, model orchestration, and workflow. They may not query the database directly or touch an HTTP request or response.
- →Routers own HTTP routing, request parsing, and response shaping. They may not contain business logic or reach the database.
- →Prompts own the prompt strings and their assembly. They may not access the database or contain workflow logic.
02The test
Then the part that makes it an architecture instead of a glossary.
If you cannot unit-test a service without mocking HTTP, the router has leaked into the service. If a service calls supabase.table() directly, a repository has leaked into the service.
Two sentences. Both are checkable with grep. Both can come back false. That is the entire point of writing them, and it is more than most architecture documents manage. So I checked.
03We fail the test
What the audit found
- →Twelve service files in the main backend call the database client directly, across roughly fifty call sites. Every one of them has a repository sitting in the next folder over.
- →In our generation service, a single service file queries three tables inline. Three hundred lines below those queries, the same file carries a comment explaining that its clean delegating methods exist so that routers never call repositories directly. It is guarding a door while the window is open.
- →A repository imports a service. That is a backwards arrow, forbidden by name in our own document, and it puts a business rule inside the data layer.
- →One domain has a repositories directory with nothing in it. Its service calls the database itself, four times, from directly beside the empty folder.
04The rot went downward, not upward
Here is where my expectations were simply wrong, and it is the most useful thing I learned.
I went looking for fat controllers. Every instinct I have says the HTTP handler is where logic goes to hide. It is closest to the request, it is the easiest place to add one more condition, and it is where every framework tutorial quietly encourages you to put things. I expected thousand line routers stuffed with business rules.
In the main backend I did not find them. The largest router in the entire domains tree is two hundred and forty nine lines, and it is a near perfect delegator: parse the request, call one service function, return the result. Not one service in that backend has the web framework's dependency injection or request objects leaked into it. I grepped specifically. The router boundary is the healthiest boundary we have.
The rot went the other way. Services quietly grew a second, private data access path around their own repositories. One domain has eleven services and two repositories, and three of those services skip the repositories entirely. Another has eight services, five of which talk to the database directly. One repository file has swollen to one thousand three hundred and fifteen lines, absorbing an entire schema on its own.
The router layer is the one everybody reads in code review. So the router layer stayed clean, and the mess went somewhere nobody looks.
That is not a coding failure. It is a review failure, and it is entirely predictable in hindsight. The HTTP boundary is legible. A diff that puts a database query inside a request handler looks wrong to any reviewer in about three seconds. A diff that puts the exact same query inside a service, in a file that already contains business logic, looks like a Tuesday.
05Why this rots faster in an LLM codebase
Everything so far would be true of any web backend. Two things make a model-driven codebase decay faster at exactly this boundary, and I have not seen either discussed much.
The first is that prompts are a layer, and almost nobody treats them as one. In our generation service the prompts directory is twenty thousand seven hundred lines. That is larger than the router layer and the repository layer put together. Prompts are not configuration and they are not comments. They are the largest body of behaviour in the system. If you do not give them a home they do not evaporate; they end up inline in your services, then in your routers, and now your HTTP handler is doing prompt assembly.
We did give them a home, and we even built the right abstraction for it: a sixty six line prompt compiler that takes typed sections, enforces a fixed order of task, context, inputs, quality contract, output schema, and final instruction, and silently drops the sections you left empty. It is pure, it has no dependencies, and it is trivially testable. It is the best designed thing in the service.
Four call sites, and one of those four is in a router. An abstraction does not adopt itself. Writing the good version is the easy half of the work, and it is the half that feels like progress, which is exactly why it is the half that gets done.
The second problem is the one that actually breaks the test. Our model call is a module level function. You import it, you call it, and somewhere inside it reaches into the environment for an API key and raises if the key is missing.
# core/llm_orchestrator.py
def generate_response(system_prompt, messages, temperature, model):
llm = get_llm(model) # reads the API key out of os.environ
return llm.invoke(...) # raises if the key is absent
# ...and then, in every single service:
from core.llm_orchestrator import generate_response
class SomeService:
def __init__(self, supabase):
self.supabase = supabase # the database IS injected
def do_the_thing(self, x):
return generate_response(...) # the model is NOTLook closely at that constructor, because it is confessing something. We were disciplined enough to inject the database client. We were not disciplined enough to inject the model. Which means a service is perfectly testable right up until the moment it does the one thing it exists to do.
There is no seam. To test that service you either need a live API key or you monkeypatch a module level symbol, and monkeypatching a module level symbol is not a test of your architecture. It is a test of your patching. The fix is boring and it fits on one line.
class SomeService:
def __init__(self, supabase, llm): # inject both
self.supabase = supabase
self.llm = llm
def do_the_thing(self, x):
return self.llm.generate(...) # now a fake fits hereExactly one service in our codebase does this. It accepts a model runner in its constructor. It is the only service in the entire repository that could be unit-tested to the standard our own document demands. It has no tests.
06There was never a harness
Which brings me to the finding that made me want to write any of this down.
The architecture document proposes a falsifiable test. The service it describes has no test directory, no test configuration, no test runner in its dependency list, and not a single test that instantiates a service class. We wrote down a rule and then never built the thing that could tell us we were breaking it. The test has never been run, because there is nothing to run it with.
What the service does have is six standalone scripts you execute by hand, with bare assert statements. Every one of them tests prompts, configuration, or pure utility functions. Not one mocks a database. Not one mocks a model. And one of them imports the function under test out of the routers directory, which is the codebase quietly telling you where the logic actually lives.
And the correlation is not subtle. The two domains with the worst layering discipline are the two with the thinnest test coverage, and one of them has none at all. The biggest and most frequently changed domains are the ones that violate the rule most. That is not a coincidence you can shrug at. It is a mechanism, and it runs in one direction.
07The control group is in the same repository
The encouraging part of this story is that the counterexample is sitting right there next to the mess.
Our newest service was built later, smaller, by people who had read the document. It has one repository per table, nine of them, and not one exceeds three hundred lines. It has zero database calls in its service layer, which is the exact rule the main backend breaks in twelve files. And it has real tests, which import a service directly, hand it a fake client, and assert on what comes back. There is no HTTP test client anywhere in the suite.
It is not spotless. Its routers reach past the services and import repositories directly, and one of them writes a log row to the database inline. That is a better class of violation, because the SQL is at least still centralized in repositories, but it is a violation and I am not going to pretend otherwise.
What that service proves is not that the pattern is easy. It proves that the pattern holds for as long as going around it is still harder than going through it. That window closes on its own. Our oldest domains went through it and out the other side, and nothing stopped them on the way.
08What actually holds a layer up
Three things can enforce a boundary, and they are not equally good.
In increasing order of how much they actually work
- →A document. This is where we were. Worth writing, because you cannot enforce a rule you have never stated, but on its own it does nothing at all. Ours was at least honest enough to call itself a target rather than a description. I would still call that a failure, because a target with no deadline is a wish.
- →A test. Make the rule executable. The happy news is that this particular rule is close to trivial to automate: import every module under services, walk its imports, and fail if any of them is the database client or the web framework. That is about twenty lines, and it would have caught all fifty violations on the day each one was written, in the pull request that introduced it.
- →A shape that makes the violation harder than the alternative. This is the only one that scales, because it does not depend on anybody remembering anything. If a service is handed a repository object and never sees a database client, it cannot call the database. Not because it is forbidden, but because it has nothing to call it with.
We hand our services the raw database client and then ask them politely not to use it. That is not a design. It is an honour system with a folder structure.
There is not one class statement in any repository module in the entire codebase. I checked. Every repository is a flat module of functions that receives the client as an argument, and every service is handed that same client. The consistency you see when you skim the tree is convention, not enforcement. Nothing in the type system prevents a service from doing precisely what twelve of them went ahead and did.
09What I would do on day one
Write the rule down. Then, in the same week, write the test that fails when the rule breaks. If you find you cannot write that test, your rule is not a rule. It is a preference, and you should either sharpen it until it is checkable or stop pretending it governs anything.
And in a model-driven codebase specifically: inject the model. Almost everything else follows from that single decision. A service that receives its model can be tested. A service that imports its model cannot. That is the whole distance between an architecture you can verify and an architecture you can only describe, and we spent a year on the wrong side of it without noticing, because nothing was ever going to tell us.
