Four years into the codebase, a product engineering team realizes velocity has quietly halved. Pull requests that used to take a day now take three. New engineers take six weeks to ship their first meaningful change, not the expected two. The default diagnosis is: we need better architecture. We need microservices. We need a new framework. We need more senior hires. The usually-correct diagnosis: we have too much code. Specifically, we have abstractions that were added to solve problems we no longer have, and we've forgotten why they're there. Every new feature has to work around them, because removing them feels risky. - What the codebase looks like at year 4 You will recognize the pattern in any mid-aged Python codebase. It's not bad code; it's evolved code. A sample of what tends to accumulate: - A custom dependency-injection container, written by an engineer who has since left, that wraps a framework that now does DI natively. - An abstract base class with one concrete implementation, added "in case we need to swap it later". It's been three years; you haven't. - A repository pattern that hides SQLAlchemy behind a thin wrapper. The wrapper has fewer features than SQLAlchemy, so you routinely bypass it. - A service layer that just forwards calls to the repository layer, which forwards calls to the ORM. Three hops to load a user. - A settings-management system with five layers of environment overrides, built before `pydantic-settings` was mature. Now you have a choice: migrate to the library or maintain yours. - A retry decorator is used in twelve places, and every place has different failure modes, and half of them have a bug where they retry on the wrong exception. Each of these, at the time it was added, was reasonable. Cumulatively, they're the reason your new engineer spends their first week reading code to understand what `UserRepository.get_by_id()` actually does versus the one-line SQLAlchemy query they'd write from scratch. - The deletion audit The week I'm describing went roughly like this, and the pattern generalizes. Day 1: Measure dead code.** Run `vulture` over the repo with a confidence threshold of 80%. For a 40-50k line Python codebase, you typically get 500-1,500 "possibly unused" hits. About 30-40% are genuine false positives (decorators, dynamic imports, framework-called methods). The rest is a list of things to verify and delete. An afternoon of grepping each candidate for invocations, plus a local test run, usually kills 5-10% of the codebase with zero functionality change. Day 2: Single-implementation abstractions.** Grep for `class.*ABC` or `@abstractmethod` and walk each one. For every abstract base class with exactly one concrete subclass, ask: did we ever actually need the abstraction? In the codebase I'm thinking of, there were 23 ABCs. 19 had one implementation. We deleted the abstraction, kept the concrete class, and renamed it to match what the ABC was called. Net: -800 lines, -1 layer of indirection, same behavior. Day 3: Wrapper libraries that wrap less than they claim.** A custom `HttpClient` that wraps `requests` but only exposes 60% of the API. A `ConfigLoader` that wraps environment variables but adds a caching layer nobody needed. A `JsonSerializer` that wraps `json.dumps`. Each of these has to be audited carefully — the wrapper usually adds *some* value, even if it's just a consistent import path. But in practice, 60-80% of wrappers I've seen either re-implement library features poorly or hide library features developers need, forcing bypass patterns. The deletion pattern: for each wrapper, grep every use site. If every use site uses the wrapper identically, replace it with a shared utility function. If the use sites diverge, replace with direct library calls. Either way, the wrapper goes. Day 4: Repository pattern audit.** This is the big one. The repository pattern was introduced in the early 2000s as a way to abstract away the ORM so you could swap persistence layers. Almost nobody has ever swapped persistence layers. The pattern has become ceremony. In practice, a service-calls-repository-calls-ORM chain usually looks like: ```python # In UserService def get_user(self, user_id): return self.user_repository.get_by_id(user_id) # In UserRepository def get_by_id(self, user_id): return self.session.query(User).filter(User.id == user_id).first() ``` The service layer adds zero behavior. The repository adds zero behavior. Delete both, let the handler call SQLAlchemy directly: ```python def get_user_handler(request, user_id): user = db.session.query(User).filter(User.id == user_id).first() return UserResponse.from_model(user) if user else None ``` Four lines instead of two method calls across two files. More testable, not less — you can mock the session or use a test database. The "we might swap ORMs" argument evaporates once you realize that the migration would be smaller than the ongoing tax of maintaining the abstraction. Day 5: Consolidate retry logic, then delete f…