Skip to content
FaxterBook a diagnostic

Technical companion

23 min readBuilding Faxter · Part 2.5

The Five Architectures I Threw Away Before Clerk Worked

The monolith, the wrappers, the Accounting OS refactor, the dual-agent system, the clean rebuild — and what each one cost to learn.

  • Engineering
  • Architecture

A technical companion to “Vibe Coding with Claude Code.” If the previous essay was about what I learned, this one is about what I built, rebuilt, and deleted on the way to learning it. It is aimed at engineers. It contains opinions I could not have held before making the mistakes that produced them.


Why this essay exists

Most architecture post-mortems are written by the winners. Someone builds a system that works, and then they write about the elegant sequence of decisions that got them there, with the dead ends sanded off and the wrong turns quietly reframed as “early iterations.” I find these essays mostly useless. The interesting information is in the wrong turns. The elegant final architecture is downstream of a dozen embarrassments, and if you only publish the elegant final architecture, the next person repeats the embarrassments.

So this is the unsanded version. Clerk went through five distinct architectures before I landed on one I was willing to ship. Four of them were wrong, the fifth mostly worked, and then I threw the whole thing away and rebuilt it anyway. Each phase was internally rational — every layer I added made sense given what I knew at the time. The lesson is not “I was stupid.” The lesson is that local rationality does not compose. You can make the right decision at every step and still end up somewhere you should not be.

Here is the map of how that happened.


Phase 1: The monolithic AI agent

The first version of Clerk was a single file. It was called ai_agent.py, it was about 2,300 lines of Python, and it did everything. It parsed the user’s intent, it decided what accounting operations to perform, it executed them against the database, and it formatted the response. One file. One class. One method you called with a string, that returned a string.

User Request → AIAgent.process_instruction() → Response

You would laugh, except you have also done this. Every ambitious project starts with a god class. You start with a god class because you don’t yet know where the seams are, and premature modularization is worse than no modularization, and so you put everything in one place and tell yourself you’ll split it later.

The thing is: it worked. Users could talk to their books. The AI understood “show me my profit last month” and returned a real answer. The basic loop — natural language in, double-entry bookkeeping out — was real. I had Nigerian VAT, WHT, and CIT wired up. I had WhatsApp, Slack, and email channels. I had invoices and inventory. It was, by any honest measure, a working accounting system.

And it was completely unmaintainable. Here is what a 2,300-line god class actually costs you:

Every test was a live OpenAI call. There was no caching, no mocking, nothing. Each test took five to ten seconds and cost real money. Writing a test suite was literally more expensive than writing the feature it tested. So I wrote fewer tests. So I broke things more. So I wrote even fewer tests, because fixing the broken things kept me too busy. You know this spiral.

Every change touched the whole file. Want to change how a response is formatted? You are now editing the same function that handles database transactions. Want to add a new operation? Find the right if branch in a 400-line dispatch method and pray. The cost of a change was roughly proportional to the size of the file, and the file kept getting bigger.

Every response sounded the same. Because formatting was a single function with no context awareness, every transaction confirmation came out as “Transaction created successfully.” A system whose entire selling point was that it felt like talking to a person felt, in practice, like talking to a form.

But — and this is the part that matters — it shipped. Users used it. It did real work. The pain was invisible to them. The pain was mine. And that is exactly the situation in which you make the next mistake.

Phase 2: Enhancement through wrappers

When a god class becomes unmaintainable, the first instinct of any engineer who has read a book about software architecture is to wrap it. You don’t touch the god class, because touching it is dangerous and breaks things. Instead you build a new layer around it, and you put new features in the new layer, and you tell yourself that over time the new layer will grow and the god class will shrink and eventually everything will be clean.

I built two wrappers. The first was ai_agent_enhanced.py, about 960 lines, which added transaction categorization, spending analysis, anomaly detection, and forecasting — things the base agent didn’t do, which I wanted to add without touching the base agent. The second was accounting_os_integration.py, about 270 lines, which bridged the enhanced agent to a new set of specialized action executors in app/services/action_executors/.

The architecture was now:

User Request → API → Enhanced AI Agent → Base AI Agent → Action Executors → Database

This felt correct. It followed separation of concerns. It followed the single-responsibility principle. It was modular. I was proud of it. I had “refactored the god class” in the sense that I had stopped making it bigger — though I had not, notably, made it smaller.

Here is what I had actually done. I had added 1,230 lines of wrapper code without solving any of the underlying problems. The base agent was still monolithic. Testing was still expensive. The response formatter was still generic. All I had accomplished was adding more layers for a request to traverse before the actual work happened, and I had introduced a subtler problem that I did not yet see: two ways to do things.

You could call the base agent directly or you could go through the enhanced agent. Sometimes one was correct, sometimes the other. Different code paths inside the system picked different ones for different reasons that were not written down anywhere. Over time, “the way to do X” diverged from “the other way to do X” in ways that were subtle and occasionally contradictory. When something broke, the first debugging question was not “what is wrong” but “which path was this request on.”

This is the “two ways to do things” anti-pattern, and I would meet it again, larger, in Phase 5. I did not learn my lesson in Phase 2. I was too busy feeling good about having added structure.

Phase 3: The Accounting OS revolution

By the end of Q3 2024, the wrappers were also unmaintainable, and I decided the problem was that I hadn’t been radical enough. So I did a real refactor. Not add-a-layer, but actually take the system apart and put it back together around a clean mental model. I called the result the Accounting OS, because apparently I had decided I was building an operating system now.

The idea was a sharp separation of concerns, spread across four layers:

Planning layer. An ai_planner.py that turned natural language into structured execution plans. A tiered_planner.py that classified requests by complexity to optimize costs — simple queries routed to a ~2K-token path, standard operations to an ~8K-token path, complex workflows to a ~19K-token path. This was genuinely clever cost engineering. The tiered planner, once it was working, cut LLM spend by about 92%.

Execution layer. A query_engine.py for reads, an action_engine.py for writes, an educational_engine.py for when the user was learning rather than transacting, a compute_engine.py for calculations, and a memory_engine.py for conversation context. Each engine did one thing. Each engine was testable in isolation.

Orchestration layer. An orchestrator.py that coordinated the whole pipeline. A composite_operations/ package that let me define multi-step workflows declaratively, in JSON-like structures, so that non-developers could in principle read them. A capability_registry.py that held a centralized catalog of the forty-plus operations the system supported.

Response layer. A three-stage pipeline that replaced the old monolithic formatter. Stage one generated markdown content, either from an LLM or from templates. Stage two generated assets — charts, invoice PDFs — and uploaded them to S3. Stage three rendered the output for the target channel: WhatsApp got compact mobile-friendly output, Slack got rich blocks, email got HTML, the API got structured JSON.

The full pipeline looked like this:

User Request (Natural Language)

Orchestrator (coordinator)

Tiered Planner (classify complexity)

AI Planner (generate execution plan)

Execution Engines (do the work)

Response Formatter (3-stage pipeline)

User receives formatted response

I want to be fair to this architecture, because parts of it were genuinely good. The tiered planner really did cut costs by an order of magnitude. The three-stage response pipeline really did let me serve mobile and desktop and API clients from the same backend. I was able to add LLM-response caching for tests, which finally made the test suite fast and cheap. The code was, on a per-file basis, cleaner than anything I had written before.

I was also, without realizing it, digging the hole I would eventually have to climb out of.

Here is what went wrong, and it is the thing I now watch for in myself whenever I start designing a system: I was building for hypothetical future needs, not actual present ones. The tiered planner optimized a cost problem I did not actually have yet. The composite operations framework let me declare workflows I never had to declare. The capability registry catalogued operations whose existence was already findable by grepping. The three-stage response pipeline supported seven channels; in practice users cared about two.

Each of these components was defensible. Each solved a real problem or a plausibly-real-near-future problem. Together, they formed a system where every request had to traverse seven boxes to get from the user to the database, and where every new feature required touching four files in three packages and updating the capability registry and writing a composite operation and possibly teaching the planner about a new tier. I had built a framework. I had not built a product. The framework was impressive. The product was now harder to change than it had been before the refactor.

This is the “smart framework” anti-pattern, and I did not see it while I was inside it. From inside, it looked like good engineering. From outside, and from the vantage point of the rebuild, it looked like a very elaborate apparatus for making simple things hard.

Phase 4: Cleanup and consolidation

By December 2025 I had been living with the Accounting OS for a year, and I had also been quietly building features on top of it, and the Enhanced AI Agent and the accounting_os_integration.py layer from Phase 2 were still there, still being called by parts of the system, still doing things that the Orchestrator could now do directly. I did an architecture audit. I wrote it down. The audit’s conclusion was simple and brutal: 1,233 lines of wrapper code could be deleted.

ai_agent_enhanced.py: gone. accounting_os_integration.py: gone. The simplified architecture was now:

API → ExecutionOrchestrator → Done

No wrappers. No integration layers. Just direct calls.

Deleting those 1,233 lines was one of the most satisfying things I have ever done in a codebase, and it was also a warning I failed to heed. The warning was this: every layer I had ever added had been added for a reason that seemed good at the time, and every one of those layers had eventually become dead weight that a future version of me had to delete. I was now batting 100% on “layers I was once proud of that I later had to remove.” You would think, at some point, I would have generalized from this. I did not. Not yet.

What I concluded instead was that I had been almost right. The Orchestrator was the correct architecture, I thought. I had just wrapped it in unnecessary things. Now that those were gone, the system was clean, and I could get back to adding features. This is called the “almost” trap, and it is how you miss lessons that are staring you in the face.

Phase 5: The LangGraph addition

In January 2026 I finally gave LangGraph a serious look. I want to be honest about this, because it is the part of the story I am most tempted to revise in my own head. I had known about LangChain for a long time. I had experimented with it. I had read the docs, I had built small things with it, and I had decided — deliberately, with what I thought was good reasoning — that I would rather build something more specific to my needs than adopt a general-purpose framework that didn’t know anything about accounting. This is a decision a lot of engineers make, and it is almost always wrong, and it was wrong here.

For any reader who has not used it: LangGraph is a framework for building LLM agents using the ReAct pattern — reason, act, observe, reason again, act again, until done. It is, in my opinion now, the correct abstraction for this class of problem. It is flexible, it is transparent about its reasoning, it chains tools naturally, and it does not require you to build a planner because the model is the planner. I had seen all of this before. I had decided I could do better. I could not.

In January I tried it properly. And here is the decision that cost me the most:

I decided to add LangGraph rather than replace the Orchestrator.

The reasoning was that the Orchestrator was efficient and cheap — the tiered planner kept costs down — and LangGraph would be more expensive because it made multiple LLM calls per request in its reasoning loop. So I built a hybrid: users (or tenants, or environment variables) could choose which agent handled their request. LangGraph for chat and exploration and multi-step reasoning. Orchestrator for API endpoints and batch jobs and cost-sensitive operations.

I even built a llm_factory_adapter.py that routed 90% of LangGraph’s model calls to DeepSeek V3 (cheap) and 10% to GPT-4o (for vision), which meant that in practice LangGraph cost only slightly more than the Orchestrator. I was, once again, quite proud of this. It was technically clever. It preserved all the work I had done on the Orchestrator. It let me bring in the new capability without throwing anything away.

And it reintroduced, at a bigger scale, the exact problem I had solved in Phase 4. Two ways to do things. Two agents. Two codepaths. Different error handling. Different memory management. Different progress tracking. Different conventions for how tools were registered, different conventions for how responses were formatted, different conventions for how failures propagated. Users could not tell which agent was handling their request. Feature parity between the two agents was a constant running battle. Every new capability had to be implemented twice, or implemented once and then awkwardly bridged, and either way the result was that the system’s behavior depended on a configuration setting most users did not know existed.

I had built two good systems that together formed a bad system. This is the thing about the “two ways to do things” anti-pattern that I want to drive home: it does not matter how good each of the two ways is. The problem is not the quality of either path. The problem is the existence of the fork. A fork in a system is a tax on everyone downstream of it — every developer, every tool, every test, every user-facing behavior, forever, until the fork is removed. The tax compounds. It is almost always larger than the benefit of having the second path.

I did not see this for about a month. Then I started to see it, and then I could not stop seeing it, and then I had to decide what to do about it.

Phase 6: The decision to rebuild

I want to be careful here, because “I decided to rebuild from scratch” is the kind of sentence engineers love to say and almost always regret. The second-system effect is real. The rewrite that was going to take three months takes eighteen. The new system is worse than the old one in ways nobody anticipated. Everyone who has been in this industry for ten years has at least one rewrite horror story, and the canonical advice is: don’t.

I rebuilt Clerk from scratch. I am going to tell you why I think this case was an exception, and what I did to make it not a disaster, because I think the rules are different now and I do not see people writing about why.

The conventional argument against rewrites is that the old system contains an enormous amount of accumulated knowledge — edge cases handled, bugs fixed, user feedback incorporated — that is not written down anywhere and that a rewrite will silently lose. You will rediscover every one of those edge cases the hard way, and each rediscovery will cost you a user’s trust. This is a completely correct argument and it is why most rewrites are a bad idea.

It assumes, however, that the only way to transfer that accumulated knowledge to the new system is to keep the old code. That assumption was true when the only thing that could read code and turn it into insights was a human. It is not true anymore.

What I did, before rebuilding, was this. I asked Claude Code to read the entire existing codebase — all six phases’ worth — and produce a lessons-learned document. Not a summary. Not a feature list. A brutally honest account of what had worked, what had not worked, what anti-patterns I had fallen into, what architectural decisions had compounded into pain, and what the system would look like if I built it again knowing what I knew now. I told it not to be diplomatic. I told it to be specific. I told it to name the files and the decisions.

It produced about ten articles’ worth of prose. The best parts were the ones I already suspected but had not let myself write down. The dual-agent system was fundamentally flawed — not because either agent was bad, but because users needed one way to do things. Templates in the response pipeline were a mistake; responses should have been 100% LLM-generated from day one. The tiered planner was premature optimization; LangGraph’s ReAct loop handled complexity natively and the cost savings I had engineered were solving a problem I no longer had. Accounting rules in Python code were a category error; they belonged in a pluggable knowledge base the model could read. The three-stage response formatter was mixing responsibilities that should be separated. I had 80% of too many features instead of 100% of the few features that mattered.

I saved this document. I called it LESSONS-LEARNED.md. It now lives in the root of the new repo, and I load it into the context of every serious coding session. It is the thing that carried the accumulated knowledge of the old system across the rewrite boundary. Without it, the rewrite would have been an act of faith. With it, the rewrite was an act of engineering.

I did one more thing before starting. I used the lessons document as the prompt for the rebuild. I told Claude Code: build Clerk again, from scratch, with these lessons as hard constraints. No dual agents. No templates. No hardcoded rules. No tiered planner. No composite operations framework. No response pipeline with more than one responsibility per stage. One LangGraph agent. One way to do things. Tools as the only unit of extensibility. The minimum path from API to database.

Then I sat with it for a week.

The rebuilt system was done in seven days. One hundred percent AI-generated code — and I mean that literally, in the sense that I did not type code, I directed code. I read every diff. I redirected when the model started sneaking in abstractions that the old system had taught me to fear. I refused several “small improvements” that were the same mistakes dressed in new clothes. But I did not write the code myself, and the fact that I did not write the code myself is, I now believe, the reason the rewrite worked.

Here is the counterintuitive part, and I want to say it clearly because I did not expect it and it changed how I think about rewrites.

A human rewrite would have failed. I know this because I know me. I would have gotten to the response formatter and I would have thought “well, the three-stage pipeline was actually pretty clean, I’ll just keep that bit,” and the old complexity would have smuggled itself back in. I would have gotten to the capability registry and I would have thought “I did put a lot of work into this and it’s not that bad,” and kept it. I would have looked at the tiered planner and remembered the 92% cost savings and found a reason to preserve it in some reduced form. Every piece of the old system that I had been proud of would have found its way back, because I would have been doing the rewrite and I would have been attached to all of it.

The AI was not attached to any of it. I handed it the lessons document and said build, and it built what the lessons document described, not what my sentimentality wanted. When I asked it to preserve something the lessons had flagged as a mistake, it did, but I could see on the page that I was preserving a mistake, and I could not hide from that the way I could hide from it inside my own head. The AI was a pitiless editor. It was the first collaborator I had ever worked with who did not need to be convinced of what should go away. It just let things go away, and then I had to either agree or admit that I was keeping something for reasons that were not engineering reasons.

The new architecture, the one I actually run Clerk on now, is absurdly simple compared to what came before it:

User → API → LangGraph Agent → Tools → Database

That is it. There is no orchestrator. There is no planner. There is no response formatter. There are tools, and there is an agent that decides which tools to call, and there is a database behind the tools. Every piece of complexity that used to live in the framework now lives in the model’s reasoning or in a tool’s implementation, and nowhere else. The system is smaller than the last three versions of itself. It is also faster, more capable, and easier to change. It was supposed to take a month. It took a week. It has been running in production since it was finished, and I have not yet found an architectural decision in it that I want to reverse.

What I actually learned, stated as plainly as I can

Six phases of architecture, three years of work, two rebuilds, and a lot of code I was wrong about. Here is what I took from it, compressed into the form I wish someone had handed me at the start.

Local rationality does not compose. Every layer I added was defensible on its own terms. Every layer I later deleted was also defensible on its own terms — at the moment of deletion. The failure mode of a system is not that bad decisions were made. The failure mode is that good decisions accumulated, because there is no natural mechanism by which good decisions become bad ones except the passage of time and the accumulation of other good decisions around them. You need to periodically step back and look at the whole, not the parts.

“Two ways to do things” is a tax that compounds. I met this anti-pattern twice and I am more afraid of it than any other single thing. Every fork in a system is a tax on everyone downstream — every developer, every tool, every test, every user. The tax compounds. It does not matter how good each fork is in isolation. The problem is the fork. If you find yourself about to build a second way, stop, and ask whether you can make the first way better instead. The answer is almost always yes, and the work is almost always less than maintaining two paths for the rest of the system’s life.

The better the model gets, the less you should build around it. Every abstraction I put around the LLM — planners, orchestrators, tiered routers, composite operations — was a bet that the model was dumber than it actually was. That bet kept losing. The model kept catching up to and surpassing the scaffolding. The only architecture that has not embarrassed itself against model improvements is the one where the model is given tools and allowed to decide how to use them. I am now quite radical about this. If you find yourself building a framework to manage what the model does, you are building something the next model release will render obsolete. Build tools instead. The tools are the durable part.

The “smart framework” anti-pattern feels like good engineering from inside. This is the one I am most worried about repeating. When you are in the middle of building a framework, it feels like exactly what you are supposed to be doing. You are creating abstractions. You are enabling future flexibility. You are following the principles you were taught. But the flexibility you are enabling is usually for needs you do not actually have, and the abstractions you are creating are usually making the simple case harder in exchange for making the hypothetical complex case possible. Most complex cases never arrive. The tax on the simple case lasts forever.

Deprecation over deletion is good discipline, until it is cowardice. I kept dead code around long past the point where anyone was using it, because deleting it felt risky. In every case, deleting it turned out to be fine. In every case, I wished I had done it sooner. “Commented out with a deprecation note” is a useful step; it is also a trap if you never take the next step. Set a date when the deprecated code is deleted, and delete it on that date, and accept that if something breaks you will find out and fix it.

Rewrites are different now. This is the claim I am least sure of and most interested in, because it contradicts twenty years of industry wisdom. The old argument against rewrites was that the accumulated knowledge in the old codebase could not be transferred to the new one without simply copying the old code. That argument assumed the only readers of code were humans. It is no longer true. An AI that can read an entire codebase and produce a structured lessons-learned document is a new kind of tool, and it changes the economics of rewriting. Not enough to make rewrites routinely correct — most rewrites are still a bad idea — but enough to make them correct more often than they used to be. The key is that the lessons document has to exist, it has to be honest, and you have to use it as a constraint on the rewrite, not a reference.

A rewrite done by an AI is less sentimental than one done by you. This is the part I am still absorbing. I have watched myself, repeatedly, preserve bad decisions because I was attached to the work that went into them. The AI has no such attachment. When I gave it the lessons document and told it to build the new system, it built the new system. It did not sneak the old complexity back in. It did not get halfway through and decide the old pipeline “wasn’t so bad after all.” It executed the lessons ruthlessly. I have come to believe that this ruthlessness — which is the correct kind of ruthlessness, directed by a human who knows what the lessons should be — is worth something that I do not see discussed often enough. Rewrites fail, in large part, because the person doing the rewrite is too close to the old system to burn it properly. An AI is not too close. An AI is exactly the right distance.

The lessons-learned document is the most valuable file in your repository. Not the architecture doc. Not the README. Not the specs. The lessons-learned document. Because it is the only file that captures what you would do differently, which is the only form of knowledge that transfers across rewrites and onboarding and team changes. I should have started keeping one from the first day of Phase 1. I did not. I started keeping one in Phase 6, and it is now the first thing I load into context for any serious change to the codebase, and it has already prevented me from repeating several mistakes I could feel myself drifting toward.

What I would tell myself at the start

If I could send a message back to the version of me who was about to build ai_agent.py on the first day of Phase 1, it would be short. Six sentences.

Start with LangGraph. You do not need to build a planner; the model is the planner. You will want to build a framework; do not build a framework, build tools. The first abstraction you add to the model is the first abstraction you will later delete. Keep a LESSONS-LEARNED.md from day one, and load it into context every session. If you find yourself about to build a second way to do something, stop, and ask whether the first way is actually the problem.

That would have saved me three years and five architectures. I would not have believed it, because I did not have the scar tissue yet. That is the honest difficulty with this kind of essay: the people who most need the lessons cannot yet recognize them as lessons, and the people who can recognize them have already paid for them. I am writing this anyway, on the theory that a few of you will read it now and remember it later, at the exact moment you are about to add your own first abstraction to a perfectly capable model.

When that moment comes, I hope you hear me, and I hope you put the abstraction down, and I hope you build tools instead.

Faxter

We build the co-workers this is written about.