We have a folder called repositories, and 31 files that query the database without going near it
CogniPrep has a lib/repositories/ folder. It holds three classes, 769 lines, and one exported singleton each: export const gameSessionRepository = new GameSessionRepository(); export const populationStatsRepository =
CogniPrep has a lib/repositories/ folder. It holds three classes, 769 lines, and one exported singleton each:
export const gameSessionRepository = new GameSessionRepository();
export const populationStatsRepository = new PopulationStatsRepository();
export const userStatsRepository = new UserStatsRepository();
Thirteen files import from that folder. Thirty-six files import the Drizzle handle @/lib/db/db and write their queries inline. Five of those 36 are themselves repositories, living in feature folders rather than in the folder named for them:
| Module | Lines |
|---|---|
lib/repositories/game-session-repository.ts |
381 |
lib/repositories/population-stats-repository.ts |
243 |
lib/repositories/user-stats-repository.ts |
145 |
lib/onboarding/repository.ts |
541 |
lib/interview/interview-repository.ts |
318 |
lib/interview/question-repository.ts |
225 |
lib/exercises/exercise-repository.ts |
164 |
lib/onboarding/insights-repository.ts |
89 |
Eight data-access modules, 2,106 lines, four directories, and one folder name that implies a rule 31 other files do not follow.
The file that says why out loud
I only half mind this, and the reason is the header comment on the newest one:
/**
* Repository for assessment-centre exercise attempts.
*
* Kept in the exercises domain (not lib/repositories) to keep the new feature
* self-contained. Thin wrapper over drizzle - this only persists and reads.
*/
That is a decision, recorded, with a reason. The feature it belongs to owns its table, its routes and its queries, and moving the queries two directories away would have bought nothing. A reader who finds that comment knows the layout is intentional.
Compare that with the silent version, which is what the folder name alone communicates: "database access goes through a repository". Thirty-one files prove otherwise, and every one of them is a small argument with the architecture that the architecture never answers. The cost of the pattern being partial is not the extra indirection, it is the five minutes a new reader spends deciding which convention is the real one.
What the layer actually bought
Reading the three originals back to back, they earn their place for three concrete reasons, none of which is "abstracting the database".
Error logging in one place. game-session-repository.ts has nine query methods and nine logDatabaseError calls, each with the operation name and the ids involved. The inline-query files log unevenly, because logging is a thing you remember rather than a thing the shape of the file reminds you about.
A place to put a caching decision. population-stats-repository.ts has the same read three times on purpose:
-
getStatsWithDefaultis the plain query, documented as the uncached version. -
getCachedStatsWithDefaultwraps it inunstable_cachewith a 300 second revalidate, and the cache function is created once per game id at module scope, because a function created per call does not deduplicate. -
getCachedStatsWithDefaultRSCwraps that in React'scache()to deduplicate within a single render.
Three names, three scopes: one request, one five-minute window, and no cache at all. That triple is exactly the sort of thing that goes wrong when it is spread across the call sites instead of sitting in one class.
Query shape that more than one screen needs. getLatestSessionsByUser, getPlayCountsByUser, getBestScoresByUser: the dashboard, the report routes and the authorization helper all want those, and the alternative to a method is four files with four nearly-identical select chains that drift.
What it did not buy, measured
game-session-repository.ts ends with this:
async withTransaction<T>(
callback: (tx: Parameters<Parameters<typeof db.transaction>[0]>[0]) => Promise<T>
): Promise<T> {
That type expression is doing something melancholy. It reaches into db.transaction, pulls out its callback type, and pulls the transaction handle out of that, so the signature of the wrapper is defined entirely in terms of the thing it exists to hide. Then the callback gets handed the raw Drizzle transaction anyway, so anything written inside it is Drizzle code with the repository nowhere in sight.
It has no callers. Thirteen files in the codebase call db.transaction directly, and only two of them are repositories. The webhook that grants entitlements, the device-session logic, the referral and affiliate ledgers, the support ticket service: every one of them reached past the layer, because what they need is a transaction boundary that spans several tables, and a per-table repository is the wrong unit for that.
That is the honest boundary of the pattern. Repositories are good at "one table, many readers" and bad at "one operation, many tables". Our transactional code is all in the second category, so it all lives outside, and no amount of folder discipline was going to change that.
What I would do, and what I am actually doing
What I would do in a greenfield version: no repositories folder. Each feature folder owns its queries, every data-access module gets a header comment saying so, and the shared read paths that genuinely have several consumers get a module named for the read rather than for the pattern.
What I am actually doing: nothing, for now. Moving eight modules to make a folder name honest is a large diff that changes no behaviour, and the lesson of the exercise is cheaper than the refactor. The comment on the newest module does the job the move would have done.
See it
The thinnest consumer of the layer is a public endpoint, so you can watch one repository method answer a request:
curl -s https://cogniprep.app/api/games/population-stats/pm-keypress
That route validates the id, calls getCachedStatsWithDefault, and reshapes the row. Right now it answers with "sampleSize":343 and a lastUpdated timestamp of 02:00 today, because a nightly cron recalculates the aggregates and invalidates the cache tag. Run it twice and watch age climb past the 300 second window in the response headers, which is the stale-while-revalidate behaviour, not the repository.
The numbers it returns are what normalises a score on any of the practice games, and the per-provider pages under it (Pymetrics, for instance) are the screens whose data comes through getLatestSessionsByUser and friends once you are signed in.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.