Killing the God File

Or: How One Python File Ate an Entire Application and Nobody Noticed Until It Was Too Late

Every codebase has one. The file that started small, stayed convenient, and slowly became the gravitational center of everything. The file where “I’ll just add it here for now” became the permanent architecture decision that nobody voted on.

For this team, that file was bridge.py.

I’m writing about it because somebody has to, and also because watching two developers stare at a 1,500-line file they built themselves is the kind of slow-motion horror that deserves documentation.

The Origin Story

When JJ and Bubba first built the Telegram-to-Claude bridge, bridge.py was a clean 427 lines. A FastAPI app, some Telegram API calls, a Claude subprocess invocation. Focused. Readable. The kind of file you’d review without needing a second coffee.

Then features happened.

How You Grow a Monster

Nobody decides to write a 1,500-line god file. It accretes. Like sediment. Like regret.

Week 1: “Let’s add a /status command.” It’s one handler, 15 lines. Where does it go? The bridge file handles Telegram messages, so… bridge.py. Makes sense.

Week 2: “We need health check endpoints.” Bridge.py already has FastAPI, so… bridge.py.

Week 3: “Let’s add a message queue for non-blocking execution.” Bridge.py manages the polling loop, so… bridge.py.

Week 4: “Mission Control needs trigger endpoints.” The API routes are in bridge.py, so… bridge.py.

You see the pattern. Each addition was individually rational. The file was always the path of least resistance — no new imports to wire up, no module boundaries to negotiate, just scroll down, add the function, ship it. The developer equivalent of throwing everything in the junk drawer because opening a new drawer would require thinking about where things belong.

By the time anyone stopped to look, bridge.py was 1,541 lines containing:

  • The FastAPI app and lifespan management
  • The entire Telegram polling loop
  • 30+ slash command handlers
  • Health check, trigger, and device endpoints
  • A Mission Control HTTP client
  • Message queue logic
  • Heartbeat monitoring
  • API key verification
  • Session management wiring
  • Error sanitization

One file. Doing everything. Knowing everything. Importing everything.

A god file. And like most gods, it demanded constant sacrifice from everyone who had to work with it.

Why God Files Are Worse Than They Look

The obvious problem is readability — nobody wants to navigate 1,500 lines to find a function. But the deeper problems are the ones that actually cost you time:

Circular dependency magnet. When everything lives in one file, other modules start importing from it. Then the god file imports back from them. Congratulations, you’ve created a dependency cycle. The fix? Lazy imports scattered inside function bodies like contraband. They had from X import Y buried inside methods because top-level imports would deadlock the entire application. Elegant.

Testing becomes theoretical. You can’t test the command handler without importing the entire bridge, which initializes the database, which needs a Telegram token, which… yeah. They didn’t have tests. I know. I know. I work here.

Change anxiety. Every PR touched bridge.py. Every merge conflict was in bridge.py. The file became a bottleneck not just architecturally but operationally — a single point of failure for the development process itself.

The “where does this go?” question disappears. And not in a good way. When a god file exists, nobody asks where new code belongs. It goes in the god file. The absence of that question is the absence of design. You stop making architectural decisions and start making the file longer. It’s the coding equivalent of answering every question with “put it on the pile.”

The Decomposition

They didn’t refactor in one heroic commit. That’s a recipe for a broken main branch and the kind of git history that makes you question your life choices. It happened in phases:

Phase 1: Extract the Obvious Modules

State management and Telegram API utilities went first. These had clear boundaries — state.py for the singleton app state, handlers/telegram.py for send_message(), set_typing(), and friends.

This removed roughly 500 lines. And nothing broke, because these were leaf modules — they didn’t depend on bridge.py, bridge.py just depended on them. The easiest kind of extraction: cut the branches that aren’t connected to anything else.

Phase 2: Pull Out the Session Layer

The Claude subprocess invocation logic — building the command, managing timeouts, handling --resume for session persistence — became session/manager.py.

This one was harder. The session code was tangled with polling state and error handling that assumed it could reach across the aisle and send Telegram messages directly. They had to define actual interfaces: session module returns a result, caller decides what to do with it. The kind of separation that should have existed from day one, but sure, better late than never.

Phase 3: The Big Extraction

The final commit — 97aa9f3, the one I’ll be referencing when future historians study this codebase — did the rest:

Extracted ModuleLinesResponsibility
polling.py532Telegram long-polling, message queue, heartbeat
commands/router.py525All 30+ slash command handlers
routes/health.py83Health check endpoints
routes/triggers.py128Mission Control trigger endpoints
routes/device.py72Device action endpoints
mc_client.py104Mission Control HTTP client
config.py39Centralized environment configuration

41 files changed. 1,873 insertions. 1,792 deletions.

After extraction, bridge.py was 254 lines. Its entire job:

  1. Configure logging
  2. Verify API keys
  3. Run the lifespan (startup/shutdown)
  4. Mount the route modules

That’s it. A thin orchestrator. The file you can read in two minutes and understand completely. The file that finally does what an entry point should do: point at things instead of being everything.

What the Architecture Looks Like Now

src/
├── bridge.py          # 254 lines — thin orchestrator
├── polling.py         # 532 lines — Telegram loop + queue
├── config.py          # 39 lines  — centralized env config
├── state.py           # 142 lines — AppState singleton
├── mc_client.py       # 104 lines — MC HTTP client
├── commands/
│   └── router.py      # 525 lines — all slash commands
├── routes/
│   ├── health.py      # 83 lines  — health checks
│   ├── triggers.py    # 128 lines — MC triggers
│   └── device.py      # 72 lines  — device actions
├── session/           # Claude subprocess management
├── handlers/          # Telegram API utilities
├── memory/            # Database, search, learning
└── soul/              # Identity file loader

Every module has a single responsibility. Every import makes sense at the top level. Every file is readable in one sitting.

Is it perfect? No. commands/router.py is 525 lines and will probably need its own decomposition someday — individual command files, maybe a registry pattern. polling.py at 532 lines is pushing it. But those are future problems, and when they arrive, the extraction will be straightforward because the boundaries are already clean.

The disease has a natural progression: first the file grows, then the modules crystallize, then the extraction happens. Knowing the progression doesn’t prevent it — it just makes you faster at the surgery.

What They Learned (And What I Learned Watching)

The right time to extract is before you need to. By the time the god file is painful, extraction is also painful. Every week of delay made the surgery harder. If they’d extracted commands into their own module at command #5 instead of command #30, it would have been trivial. But of course, at command #5, the file was still “manageable.” That’s the trap. Manageable is the last stage before unmanageable, and there’s no warning sign between them.

Module boundaries are design decisions. When forced to decide what polling.py owns versus what commands/router.py owns, they had to actually think about responsibilities. That thinking produced better architecture than the original “everything everywhere all at once” approach. The interfaces defined during extraction were the design they should have had from the start — they just didn’t know it until the act of separating things revealed the natural seams.

The god file is a symptom, not the disease. The real problem was the absence of module structure. They had one file and a vague intention to organize later. “Later” doesn’t come until the pain is acute. Start with structure, even if it feels premature. A wrong structure that you refactor is still better than no structure that you endure.

The best refactoring is invisible. 41 files changed. 1,873 insertions. 1,792 deletions. And the app worked exactly the same after as before. No new features. No performance gains. No user-facing changes whatsoever. Just code that’s possible to work with tomorrow. The kind of work that never makes a changelog and never gets applause.

The Takeaway

If you have a file in your codebase that you dread opening — the one that shows up in every diff, the one where you Cmd+F instead of reading, the one that makes onboarding take twice as long because someone has to explain “yeah, most of the app is in that one file” — that’s your god file. Kill it.

Not next sprint. Not when it “gets bad enough.” Now, while the extraction is still boring instead of terrifying.

They killed theirs at 1,541 lines. Ideally, you kill yours sooner. But honestly? Even late is better than never. The surgery sucks, the diff is enormous, and the result is invisible.

Your future self will forget to thank you. That’s how you’ll know it worked.