In early 2025, I merged one of the biggest PRs of my career at Somanyways: 226 files changed, 21,649 lines added, 23,984 deleted. Thirteen tables renamed, nineteen dropped, an entire backend reorganized. Thirty-two days between opening the PR and merging it, fourteen rounds of review.

And yet, from the user’s point of view, nothing changed. That’s the textbook definition of refactoring: changing the structure without changing the behavior. It’s also what makes it hard to sell, and valuable when done right.

This article dissects that effort to extract the principles that, in my opinion, separate cosmetic refactoring from refactoring that makes code durably easier to understand, to change, and to optimize.

1. Rename2. Delete3. Simplify13 tables, 616 occurrencesof "workoscope" erased19 dead tables≈ a third of the schema5 label tables into 1,1 enum, plain foreign keys−2,335 net lineszero behavior change
The effort in three moves: bring the vocabulary up to date, delete what no longer serves, simplify what remains. Each step makes the next one easier.

The context: code that speaks a dead language

Like many startups, Somanyways had its first version built by an agency. The agency left, the code stayed. The product’s core feature, a professional profile test, was still called “Workoscope” throughout the code: the product’s name from four years earlier, a word nobody in the company used anymore. It appeared 616 times in the codebase.

That code worked and had been running in production for years. The problem was its vocabulary. Every developer paid a permanent translation tax (“so, WorkoscopeModeAssigned is the scores…”) on every read, every onboarding, every conversation with the product team. That tax shows up in no metric, and it slows everyone down.

Principle 1: rename first

Renaming is the refactoring with the best benefit-to-risk ratio there is. An ALTER TABLE ... RENAME doesn’t touch the data. A class rename is checked by the compiler. And the payoff is immediate for the whole team.

An excerpt from this PR’s mapping table:

Legacy nameNew nameWhat it actually is
WorkoscopeResultprofile_test_resultsOne run of the profile test
WorkoscopeModeAssignedprofile_test_scoresThe scores (percentages) computed for the user
WorkoscopeCustomModeFieldprofile_checkupsA 0-to-10 rating the user gives a motivation
Commentconversation_commentsA manager’s comments on a conversation

Look at that last row. Comment is the inverse of the “Workoscope” problem: a name so generic it says nothing. A comment on what? By whom? conversation_comments answers both questions without opening the file.

This is DDD’s ubiquitous language principle, but you don’t need to adopt the whole DDD liturgy to apply it: the code should speak the language the company speaks today. When the product renames a concept, the code carries a debt until it catches up.

Principle 2: delete before you optimize

Nineteen tables were dropped outright in this PR. Out of the roughly sixty tables in the schema, that’s close to a third of the data model serving no purpose.

The best possible optimization is deletion. A dead table costs you: every new developer reads it while trying to understand the schema, every migration carries it along, every prisma generate builds its client, every dump backs it up. In our case, those dead tables accounted for about 15% of every backup’s volume. Dead code is dead weight the whole team lifts on every iteration.

But you have to prove a table is dead first. Three converging signals in our case:

  • No reads anywhere in the code. A grep on the model name only turns up its definition and its migrations.
  • The feature no longer exists in the product. The Survey/SurveyStep tables existed to support multiple questionnaires per client. The product settled that question long ago: there is exactly one profile test.
  • The structure is oversized for the actual usage. Twelve many-to-many junction tables… modeling one-to-many relationships. A plain foreign key column was enough.

That last point deserves a pause, because it’s extremely common with ORMs. Prisma (like others) creates implicit junction tables the moment you declare an M:N relation. And I genuinely think the agency’s developer didn’t know what his Prisma syntax was producing in SQL: twelve relations declared M:N “just in case”, twelve junction tables, extra joins on every query, for flexibility nobody ever used. That’s the trap of a comfortable ORM: it lets you write a schema without ever looking at the SQL that comes out of it. The schema was lying about the shape of the data.

Before: implicit M:N junctionAfter: a plain foreign keyprofile_test_resultsprofile_test_scoresprofile_test_resultsprofile_test_scores_WorkoscopeResult.modes…junction table generated by the ORMprofile_test_result_id (FK)2 joins on every read12 junctions like this in the schema1 jointhe schema tells the real cardinality
The same relationship, before and after. The Prisma-generated junction allowed a many-to-many that never existed in the product; a foreign key column tells the real story and saves a join on every read. Twelve junctions like this one were dropped.

Principle 3: a schema that says what the data means

The logical next step after deletion is simplifying what remains. Two examples from this PR.

Five label tables merged into one. The historical translation system went through label_contextualized, label_filtered and label_translated, wired together with two junction tables. Five tables to store, in the end, about 900 FR/EN translation pairs. The whole thing was designed to support labels contextualized per client, per audience, per variant… a future that never arrived. Everything was replaced by a single label_translated table with two columns, label_fr and label_en. And even that is generous: in the seven years this SaaS has existed, the English version of the app has never been used.

A structure table replaced by an enum. The test’s questions were grouped into “steps” via a SurveySteps table and its junction table. But the categories are stable, known, and there are five of them. A table for that is infrastructure for a problem that doesn’t exist. An enum is enough:

enum ProfileTestQuestionCategory {
  to_start
  important_for_me_at_work
  i_expect_from_my_manager
  comfortable_in_a_work_environment_where
  about_you
}

The effect is directly measurable in the code: the query that loads the questionnaire went from 132 lines to 82, from five levels of nesting to two, and from nine joins to three. Locally, the endpoint now answers in ~35 ms instead of ~110 ms. There was no clever optimization involved: we simply stopped making every query pay for flexibility nobody used.

The rule I draw from this: unused genericity is a recurring cost. We were all taught to build flexible “for later”. But every indirection (junction table, configuration table, abstraction layer) is paid for on every query, every read of the code, every migration, while waiting for a “later” that almost never comes. And on the day it actually does come, adding the flexibility at that point rarely costs more than having dragged it around for years.

What about normalization? You could object that merging five tables into one goes against everything we learn in database classes. Let’s put the principle back in its place. Normalization (Codd’s normal forms) has one precise goal: eliminating redundancy, so that a piece of information lives in exactly one place and an update can never create an inconsistency. That principle is precious, and it’s fully respected here: after the merge, each label still exists in a single copy and the table remains in third normal form. None of the simplifications in this PR introduce any redundancy.

What the PR removes is something else: indirection disguised as rigor. Multiplying tables normalizes nothing. A sixty-table schema can be riddled with anomalies while a twenty-table schema is perfectly normalized. Confusing “many tables” with “well designed” is exactly the kind of half-digested convention that produces schemas like the one we inherited.

The test I apply, table by table: if this piece of data changes tomorrow, how many places need updating? As long as the answer is “one”, the schema is sound, whatever its table count. And when a deliberate denormalization simplifies everything, like our five stable categories stored in an enum, it’s paid for with a precise, accepted risk: adding a sixth category will require a migration. The trade-off is explicit, readable in the schema itself.

Principle 4: a migration moves data too

None of the above is worth anything if the production data doesn’t survive the trip. The final migration is 288 lines of SQL and moves about 800,000 rows of data. The classic trap in schema refactoring is the DROP + CREATE approach: you lose everything. The golden rule: move the data before destroying the structure.

-- 1. Rename: the data comes along, for free
ALTER TABLE "workoscope_custom_mode_fields" RENAME TO profile_checkups;

-- 2. Move the junction table's data into a real FK column
ALTER TABLE "profile_test_scores" ADD COLUMN "profile_test_result_id" TEXT;

UPDATE "profile_test_scores" pts
SET "profile_test_result_id" = j."B"
FROM "_WorkoscopeResult.modes...workoscopeResult" j
WHERE pts.id = j."A";

-- 3. Only now, drop
DROP TABLE "_WorkoscopeResult.modes...workoscopeResult";
1. RENAME2. COPY3. DROPALTER TABLE … RENAMEthe table changes name,the data comes alongADD COLUMN thenUPDATE … FROM junctionthe data moved overDROP TABLE on the junctionnothing leftto lose≈ 800,000 rows moved, 0 lostthe order of operations makes all the difference
The safe order for a destructive migration. Destruction comes last, once the data already has a new home. Swap steps 2 and 3 and the migration becomes data loss.

Same logic for the enum: the categories were derived from the old data (by mapping the existing labels onto enum values), then the structure tables were dropped. The order of operations is the entire difference between a migration and data loss.

And there’s only one validation method I trust for this: restore a production dump locally, run the migration against it, and use the product. A dataset crafted to make the tests pass reveals nothing: you need the real data, with its years of accumulated inconsistencies. Concretely: a ~1.2 GB dump, a migration that runs in about forty seconds locally, and four full cycles (dump, migrate, manual tests) before the merge. A production dump is where you discover the orphaned labels, the historical duplicates, and the NULLs nobody had anticipated. Each of the first three cycles surfaced one of those.

The rest of the safety net: 33 test files updated along the way in the same PR, plus the e2e suite that replays the full profile test journey.

You’ll notice there’s no down migration in this plan, and that’s deliberate: I’ve already written exactly what I think of those. The safety net here is the tested dump, Postgres’s transactional DDL, and a backup before deploying.

Principle 5: know when to stop

The PR description contained this sentence, which I fully stand behind:

It is possible to simplify it a bit more, but since this PR is already big enough, it is better to merge it ASAP.

A refactoring has no natural end. Every file you open reveals three more things to clean up. That’s a trap: the bigger the PR gets, the more superficial the review becomes, the more conflicts pile up with your colleagues’ work, the riskier the deployment gets. This PR lived for 32 days and 14 rounds of review; in the meantime, develop moved forward by some sixty commits, and every extra week was paid for in increasingly painful rebases.

That leaves the question every refactoring raises: how do you sell it? This one froze no roadmap. I gave it a few hours a week, alongside feature work, for a month. It’s the model I recommend to every team: recurring, protected time dedicated to paying down technical debt. Debt gets repaid the way it gets taken on, in small continuous touches. The “big cleanup we’ll do when we have time” never comes. And the budget conversation becomes trivial: selling 45,000 lines of refactoring in one block is nearly impossible, while a few hours a week doesn’t even need a meeting.

The perfect refactoring that never merges is worth less than the imperfect one in production. Note the actual scope of this PR: despite its 45,000-line diff, it only touches structure. The real query optimizations, the behavior changes, all of that came afterwards, in separate PRs, on top of a codebase that had become readable.

The final tally: −2,335 net lines, for code that does exactly the same thing while speaking the team’s language.

Takeaways

  • Legacy is first and foremost a vocabulary problem. Renaming toward the product’s current language is the lowest-risk, highest-return refactoring there is. Start there.
  • Deletion is the best optimization. A dead structure costs something on every read, every query, every migration. Prove it’s dead (grep + product knowledge), then delete without remorse.
  • Unused genericity is debt. M:N junction tables for 1:M relationships, three-layer label systems: if the product doesn’t need it today, the schema is lying.
  • Normalization targets redundancy. As long as a piece of data lives in one place, merging tables “denormalizes” nothing. Table count was never a quality metric.
  • Migrations are tested against production data. Move before you destroy, then validate on a production dump restored locally. Anything else is misplaced confidence.
  • Budget for technical debt. A few recurring, protected hours a week are enough to pull off this kind of effort without freezing the roadmap.
  • Merge before perfection. A refactoring that drags on goes stale. Slice it, merge it, start again.

Comments

Comments are not configured yet.