CogniPrep is a practice platform for job assessments. Every assessment provider we cover gets a hub page, and you can count them yourself: cogniprep.app/games renders one card per provider, and there are 52 of them. Open the page and view source if you like, each card links to /games/<provider-id>, and that id is the same string the code is organised around.
Until last week it was not organised around it at all.
Filed by layer
The codebase had grown the obvious way. There was a folder for state types, a folder for feedback copy, a folder for question bank metadata, and a flat directory of 263 scorer files. A provider's work was spread across all of them:
lib/games/
game-states/epso.ts
feedback/epso-insights.ts
questions/epso-bank-types.ts
scoring/scorers/eps-numerical-scorer.ts
epso-dimensions.ts
epso-scales.ts
That layout reads fine in a diagram. The problem is what it does to the one operation we perform most often, which is adding a provider.
Routing was the worst of it. scoring/scoring-engine.ts was 4,141 lines, and two switch (gameId) statements inside it accounted for 688 case labels between them. The component registry, the insight lookup, the feedback config, the bank id list and the provider company map each had their own version of the same thing: one long literal that every provider had to reach into.
So adding a provider meant editing nine shared files, and all nine edits landed in the same region of each file. We build providers on parallel branches, several at a time. Every one of those branches wanted to insert a line into the same switch, and Git has no opinion about which of two new case blocks comes first, so it asks. Over and over, for work that was not conflicting in any real sense.
Filed by provider
Each provider now has one folder, named by its id, and every hub elsewhere is a merge:
| File | Exports | Merged by |
|---|---|---|
games.ts |
meta, games
|
constants.ts (PROVIDER_META, GAME_LIBRARY) |
config.ts |
config |
game-config.ts |
components.tsx |
components |
game-component-registry.tsx |
scoring.ts |
scorers |
scoring/scoring-engine.ts |
insights.ts |
insights |
feedback/game-insights.ts |
feedback.ts |
feedback |
feedback/client-feedback-generator.ts |
question-banks.ts |
questionBanks |
questions/bank-ids.ts |
companies.ts |
providerCompanies |
provider-companies.ts |
scorers/ |
one per test | its own scoring.ts
|
The 4,141-line router is now 677 lines, and the routing part of it is this:
const SCORERS: Record<string, GameScorer> = {
...arcticShores,
...hirevue,
...usps,
// ...one line per provider
};
lib/games/types.ts went from 2,203 lines to 844, because a state shape only one provider uses now lives in that provider's folder. The bank id loader went from 689 lines to 188. Nothing was deleted, it moved: lib/games/providers/ holds 758 files.
The merge-conflict property is the point. Two branches adding two providers now create two new folders and append one import and one line to each hub. Those lines are at different offsets in an alphabetically unordered list, which Git merges without asking.
Three things that deliberately did not move
Scorers more than one provider uses. 258 of the 263 belong to exactly one provider and went into its folder. The other five stayed in scoring/scorers/. A provider folder is for work only that provider does. Where one provider genuinely reuses another's, it imports from the owner rather than copying, and the README says so, because the alternative is two copies drifting apart.
Core types. Anything two or more providers reference stays in the shared types.ts. The test for whether a type belongs to a provider is not "who wrote it" but "who imports it".
The question bank JSON. All the banks stay in one directory, and this one is not a style decision. The API route reads them off disk by id at request time, and next.config.mjs traces that directory into the deployment. Scatter them into 52 folders and you have 52 new trace entries, every one of which is a chance to ship a route that cannot find its data. I wrote about that trap in more detail in Next.js traces your imports, so the 102 files we read with fs were not deployed.
Proving a move is a move
A refactor this size is only safe if you can show the output is unchanged, and "the tests pass" is not that proof when the tests were edited in the same commit. Their import paths all changed.
Two things carried the weight. First, two of the hubs are exhaustive: PROVIDER_META and PROVIDER_COMPANIES are Record<AssessmentProvider, ...>, so the typecheck fails until a new provider has an entry in both. Registration is not something you can forget, which is exactly what you want from a map that 52 folders feed.
Second, we compared the output rather than the diff. Every export under lib/games, and the insight, feedback, scoring and registry result for every game id, was dumped before and after and checked for equality. That is the assertion that actually says "pure move", and it is cheap to write for a module whose public surface is maps keyed by id.
See it
cogniprep.app/games is the merged GAME_LIBRARY, grouped by PROVIDER_META. Each of the 52 cards goes to a hub page that one folder produces. The newest is TestGorilla, 17 tests, added the day before the split and converted with the rest of them. Its folder is now the template the README points at, and the next provider will touch one new folder plus one import and one line per hub, and no existing provider's files at all.
If your own codebase has a switch that every feature branch has to edit, the fix is usually not a better switch. It is noticing that the file is a list, and that lists can be concatenated.
Top comments (0)