And just like that, the PoC becomes production in its very first month. What a surprise.
A year later, the optimistic estimate is about the only thing left of the original experiment. There are real users, integrations, new requirements and regular releases. Development is moving fast, but the foundations are still experimental: no shared domain vocabulary, module boundaries shaped by convenience, everyone writing their own style, and tests scattered wherever they happened to fit. CI runs, but a red result doesn't necessarily stop a merge. Building and deploying are tangled together.
The system works. It even keeps growing. That's the dangerous part: as long as something is moving, it's easy to mistake accumulated technical debt for proof that the architecture is holding up.
The question isn't how to rewrite Godzilla by Friday. How do you bring a living project under shared engineering rules without stopping delivery, or turning guidelines into somebody's personal taste in PDF form?
When existing code becomes the law
There's a wonderfully efficient way to produce an engineering guidelines document. Open the repository, collect its familiar file names, class prefixes and directory layout, and declare the whole thing a standard. Send the document around. Nobody replies. Unanimous approval, right?
Including the people on vacation, on sick leave, or simply not reading email. A remarkable voting system: absence counts as consent.
Next come tickets to fix “violations” of rules nobody discussed or approved. Especially impressive when the project is a year old and the rules are three days old.
Wanting consistency isn't the problem. The direction of the reasoning is. Existing code tells us what decisions we made before. It does not prove those decisions deserve to become permanent standards. If an experimental structure no longer scales, turning it into a convention makes technical debt official policy.
I wouldn't start by banning variable names. Or by treating silence as team alignment.
The glossary. Yes, before the linter
First, I'd get the team to agree on the language of the domain.
Imagine a fictional parcel-delivery system. One developer uses delivery for a delivery request. Another means the transport operation. A third means the event confirming that the parcel reached its recipient. All three can write perfectly formatted Python. Together, they can still produce three incompatible contracts for the same word.
No tool can settle that argument. Not a linter, not a test suite, not a beautifully green CI dashboard. The meaning of a business term comes from people agreeing on the domain, not from a configuration file.
This doesn't call for a 300-page dictionary. It calls for agreed definitions of important terms: what an entity means, how it differs from its neighbors, where the term is used and which old names are misleading. Don't paper over uncertainty with a confident-sounding definition. Leave open questions open until the people who understand the business can resolve them.
Why first? Because vocabulary shapes models, APIs, events, module boundaries and eventually names. Debating a mandatory class prefix while the team disagrees about what the class represents is a surprisingly efficient way to waste an afternoon.
Architecture: take a good look at your Godzilla
Once the glossary is aligned, the next step is to inspect the actual project tree. Not the clean diagram from an architecture presentation. The real one: where domain logic lives, what counts as shared code, where tests ended up, which components depend on which others, and which classes have too many independent reasons to change.
Suppose a fictional ParcelWorkflow implements carrier API calls, rate calculations, payment persistence and notifications all by itself. A pricing change, an API change and a message-template change all lead back to the same class. File length isn't the point. The problem is four responsibilities that have no particular reason to share an implementation.
Then there are the modules with romantic names like miscellaneous_utils.py, where every function without an obvious home goes to retire. Convenient until nobody can explain what “common” actually means.
The team needs a sensible target structure: what belongs to a domain, what is genuinely shared, and where orchestration ends and business behavior begins. This is not a call to move half the repository tomorrow. First agree where new code should go, then validate the approach in a small part of the project.
In parallel, I'd ask each developer what helps and what gets in the way. A lint report cannot tell you that people wait hours for a single reviewer, cannot reproduce failures locally, or perform releases by hand. Those problems are part of the engineering picture too.
Language rules: use the tools we already have
With the vocabulary and architectural boundaries discussed, the team can agree on a technical baseline: the language's existing conventions, Ruff, formatting, type checking where it adds value, tests and relevant complexity checks.
We do not need to write a private constitution for import ordering. The tools exist. Choose a sensible default, compare it with the needs of the project, and agree on justified exceptions with the team.
Every exception should have a defined scope and a reason documented in the ticket, PR, or both. Keep the settings in project configuration and require review to change them. Disabling a check because it inconveniences one PR and declaring the same check mandatory next week isn't governance.
Legacy code may need temporary, narrowly scoped exceptions. Those should be revisited as debt is addressed, not treated as instructions for writing more legacy code.
We can inspect the architecture and run exploratory quality scans before the baseline is finalized. But deviations from a rule only count as rule-based technical debt after the rule is agreed. Set the standard, then measure compliance; otherwise the audit checks against something that existed only in one person's head.
I wouldn't ask one person to author every section, either. Different developers know Python, testing and deployment best. Have people draft small sections in their areas of expertise, discuss disagreements together, and let the document record the decisions. A document is the output of alignment, not a substitute for it.
Technical debt: a report, not a ticket avalanche
Once the baseline exists, findings can be sorted usefully. Some are mechanically fixable. Some need architectural decisions. Others can reasonably wait, provided their risks are understood.
Import sorting is mechanical: run the agreed tool on a defined set of files, review the diff, run the checks, and send one focused PR. We don't need one ticket per file. Let the machine sort imports. Humans have more interesting problems.
Now take a fictional RefundProcessor that calculates eligibility, calls a payment gateway and sends customer emails. Splitting those responsibilities requires decisions about contracts, ownership and behavior under partial failure. That's architectural work, not a formatter option. The same applies to god classes, fuzzy boundaries and duplicated complex queries. Merely renaming RefundProcessor to RefundService gives the problem a new business card.
I wouldn't combine mass formatting, algorithm changes, package moves and data migrations in a single PR. The only person who enjoys reviewing that bundle is the person who hasn't opened it yet.
Turn the findings into a manageable backlog: cost, risk, benefit, dependencies and priority. Not every issue has to become next sprint's emergency.
CI/CD: green should mean something
Now the guidelines need to stop being polite suggestions.
If tests fail and the PR can still be merged without restriction, that isn't a required check. Running checks in CI is not enough: merge protections must actually require them. Otherwise it's a notification that something broke. Thank you, pipeline. We'll admire the warning after the release.
Code review and automated quality gates have different jobs. A PR needs review by another developer on the team, not its author and not necessarily a designated tech lead. Junior developers should be involved in review discussions as a learning opportunity, even when they are not the formal approver. Reviews focus on behavior, contracts, design and risk; tools handle deterministic checks. Higher-risk changes can involve relevant domain experts without making one person the team's permanent bottleneck.
And approving code is not the same as accepting the ticket. Depending on the team's workflow, a product owner or QA tester accepts the delivered behavior. Neither step automatically authorizes a production release.
But switching on the strictest settings across a year-old repository, discovering hundreds of violations and blocking everyone is not a migration strategy. Known failures need an explicit transition path. One option is a versioned baseline file of existing Ruff diagnostics, with a comparison step in CI that rejects newly introduced findings. Another is diff-aware linting that maps reported violations to changed lines. Or enforce the agreed rules one directory at a time as modules are cleaned up. These are alternatives, not three new tools everyone must install on Monday.
Ruff's --diff is not a changed-lines linter; it shows proposed fixes. Diff-scoped linting needs an additional comparison step or tool, and it does not replace full tests. Whichever approach fits, the baseline should shrink, exceptions need documented reasons, and new changes should not silently add violations. Gates should detect regressions, not punish each PR for inherited problems.
Flaky tests aren't a reason to normalize red builds, either. Investigate and repair them. If temporary isolation is necessary, document its reason and scope rather than making “ignore it” a permanent workflow.
And CI is not CD. A successful build, an accepted merge and a production release are distinct decisions. Compatibility between components, deployment order and recovery plans matter more than a green badge. Particularly for migrations: rolling back application code does not automatically reverse data that has already changed.
Risk and capacity: neither is infinite
Every meaningful change has a failure cost. For formatting, reviewing the diff and running tests may be enough. For shared infrastructure or a data migration, the questions are different: what can break, how will we detect it, when do we stop, who accepts the risk, and how do we recover?
For a nontrivial residual risk, record the decision in the PR or linked ticket: what can fail, expected impact, why proceeding is acceptable, who approved that trade-off, and what triggers a stop or recovery. The decision-maker depends on the system and business impact. Being the PR reviewer does not automatically make someone the owner of production risk, and the consequences shouldn't silently land on whoever ends up fixing production.
Team capacity is even simpler: adding the word “urgent” doesn't create extra hours. Some tasks start now. Some go on hold, with an explanation. And some must be declined because there is no capacity. Not everything due yesterday can be delivered tomorrow.
Importantly, the order in which we agree on principles is not a rule that all implementation work must run sequentially. Structural analysis and safe CI improvements can happen in parallel. Just don't call something an objective violation of standards that haven't been agreed yet.
Naming still matters. Just not first
Now naming conventions can finally do useful work. A shared glossary tells us what a shipment or delivery means; agreed module boundaries tell us who owns that behavior. Names can encode both: predictable directories, classes and APIs make code easier to navigate and the next change easier to place. That's a technical benefit, not an aesthetic preference.
Without those foundations, consistent prefixes only make inconsistent concepts look organized. With them, conventions can be short, stable and enforceable, without turning every PR into a spelling tribunal.
I wouldn't measure success by the page count of the guidelines or the number of tickets created. I'd rather see fewer new violations, faster reviews, more predictable releases, easier-to-test code, and a team that understands why its rules exist.
Godzilla doesn't necessarily need a rewrite. We just need to stop feeding it arbitrary decisions disguised as engineering standards.