7 min read

I Deleted 110,000 Lines of Python. The Architecture Died Twice.


In May I deleted my entire Python backend in a single commit: 1,002 files, 110,080 lines gone. The commit message reads chore!: cutover to TS/Bun backend; delete Python stack. Three months later I have enough distance, and enough evidence, to say what actually mattered about that decision. Spoiler: the language was the least of it.

What the Python stack was

This wasn’t a prototype I outgrew. It was a seriously built hexagonal-DDD stack: FastAPI, Pydantic, SQLAlchemy, Alembic, with the layer discipline mechanically enforced by import-linter running ten separate contracts. API depends on Application depends on Domain, adapters implement ports, and the linter would fail the build if you cheated.

The file tree tells you what living in it was like. The same features existed four times over: 217 files in application/, 134 in domain/, 126 in api/, 59 in adapters/. To find the code that saved a recipe you needed four browser tabs.

It worked, to be clear. It had an audit that graded it well. It had a refactor roadmap. The problem was never that it was broken.

Full disclosure about how it got that way: Python wasn’t my language. My entire Python resume before this project was typing examples out of a book into the interpreter as a kid, plus a college sentiment-analysis class: Jupyter notebooks, pandas, some graphs, none of which I could reconstruct today under oath. I write TypeScript for a living, and reading the old code back, a lot of that ceremony was me trying to buy back the type system I’d left behind. Strict type-checker settings, Pydantic on every boundary, Protocol interfaces everywhere: the architecture of a developer who was homesick for a compiler and didn’t know it yet. Some people cope with rigorous discipline. I built ten import contracts.

The four taxes

What killed it was noticing that every change, for two months straight, paid the same four taxes:

  1. One concept, many definitions. A single field traveled through a request body, an ORM row, a domain entity, and a response body, each needing its own to_dict, from_dict, or from_orm adapter. JSONB columns with structured interiors needed two adapters just to round-trip.
  2. The three-file query. Domain ports were Protocol interfaces, adapters implemented them, tests stubbed both. A feature needing one new SQL fragment meant growing the port, then the adapter, then the stubs.
  3. Ceremony priced for the rare bad change, paid on every change. Most real work was single-file CRUD. The layer matrix existed to prevent the occasional dangerous change, but everyone paid the toll daily.
  4. A drifting contract. TypeScript types on the frontend were code-generated from a FastAPI OpenAPI snapshot that lived in git and was only advisory-gated in CI. It drifted regularly, which is a polite way of saying the contract lied.

The three-day bet

I didn’t debate the port. I spiked it: three days on a branch with Bun, Hono, Drizzle, and Zod, structured as vertical slices instead of layers. By day three the spike had nine working agent tools, eleven endpoints, a hundred tests running against real Postgres, and end-to-end streaming. I committed to the port before the spike ended.

That’s the first lesson I’d generalize: a port decision made by argument is a guess, and one made by a tracer bullet is a measurement. Three days of building answered questions that months of pro/con lists wouldn’t have touched.

What replaced the layers

The replacement bet was that one definition per concept beats defended layers. In the new stack, a Zod schema is the single source of truth: it IS the database column type (via Drizzle’s $type), IS the API validation, and IS the frontend’s wire type through a shared workspace package. No codegen, no snapshot, no adapters. There’s a test in the codebase whose header comment still documents the old world it replaced, describing the “4-step dance” of value object to dict to column to dict to value object. In TypeScript the dance is a typed write and a typed read.

Structure-wise, a feature became a folder: routes that only parse and format, a service file with the logic, queries inline until they earn extraction. A new endpoint went from roughly four to six file touches down to one file plus a service function. The 139-test suite at cutover ran in about two seconds against real Postgres, which changed testing from a chore into something that rides along with every change. Three months later that suite is past 1,800 tests and still fast.

The import-linter’s ten contracts were replaced by a page of discipline rules in the repo’s instructions file, plus, eventually, two small lint rules that guard the only boundaries worth guarding mechanically: no deep imports across slice boundaries, and the billing database handle stays inside the billing slice.

The architecture died twice

Here’s my favorite part of the story. Hexagonal didn’t just die in the May deletion. It died again last week, by argument this time, when I consolidated my side projects onto one backend and a small TypeScript hexagon (a billing service, ports and adapters and all) had to justify its layers on the way in. It couldn’t. The isolation I actually needed came from Postgres schemas and roles instead, and the hexagon flattened into a plain folder. That story got its own post: Three Backends Was Two Too Many. The short version that belongs here: the layers were guarding dependency direction, and the thing worth guarding was authority. Different problems, and the second one has a cheaper enforcer.

What survived both stacks

The most durable things in the codebase turned out to be product decisions, in ADRs, and they outlived both architectures untouched: the agent is the primary editor and the UI dispatches to it; any frontend transform of server data is a server bug; the server mints IDs; one stream per conversation. The Python code is gone, the hexagon is gone twice, and those rules still govern every feature I ship. Decisions age better than structures.

There’s a newer lesson too, one I couldn’t have written in May. The ceremony tax isn’t only a human cost anymore. I delegate whole slices of work to coding agents now, and an agent burning its context window walking a four-layer change is slower, dumber, and more error-prone than one editing a single folder with one definition per concept. Vertical slices turned out to be an architecture choice that made AI collaboration cheap. I didn’t plan that. I’ll take it.

What I lost, honestly

VSA is a trade, and I’m still paying on it. Storage and wire shapes now evolve together by default: last week I dropped dead columns and correctly called it an API change, which the old domain entities would have absorbed without the API noticing. Multi-step orchestration has a convention instead of a mandated home, and my tech-debt file carries an entry proving the convention leaks. Integrations get whatever seam each slice author built rather than a uniform port. The test suite requires a real Postgres in Docker forever. And two service files quietly grew past a thousand lines before their split-up passes, a shape the hexagon makes structurally hard to build.

All of it survivable for a spare-time solo project, and all of it real. There’s a proper VSA versus hexagonal scorecard coming once I have more mileage on this stack; consider this list the down payment.

Epilogue

The last living piece of the Python era wasn’t code. It was a forgotten database branch labeled development that I found this month while cleaning up my Postgres projects, still holding the Alembic migration table and a 20MB USDA food catalog from the pantry-tracking days. I dumped it to a backup file and deleted it. The whole stack fits in a 1.1MB archive now, next to the git history that can resurrect any of it.

I don’t miss it. But I’d build it again at a bigger company, with a team that needs the layers to referee each other. Solo, with agents doing most of the typing and one person doing all the deciding, the referee was costing more than the fouls.