# thirteen tables became one, and the answer key stopped leaking
The product is a learning platform, and the unit of content is an activity. A multiple choice question is an activity. So is a poll, a drag-to-order exercise, a fill-in-the-blank.
Thirteen kinds existed by the time I touched it, and thirteen tables with them. That is what you get when each kind arrives one sprint at a time and the obvious move with a new kind of thing is to give it a table.
## Thirteen models, thirteen serializers
Adding the fourteenth type meant a Prisma model, a migration, a branch in the controller, a branch in the scoring service, and a serializer. Five files minimum, none of them hard, all of them a place to forget something. The estimate for a new activity type was a week and most of that week was wiring.
// thirteen of these, and a switch that had to know all of them model QuizActivity { id String @id question String options Json correctIndex Int } model PollActivity { id String @id question String options Json } model DragOrderActivity { id String @id prompt String items Json correctOrder Json }
Tedious I could live with. The problem was what all that duplication turned out to be hiding.
## The bug nobody filed
Each type had its own serializer, and each serializer had been written by whoever added that type. Most of them did the same thing: take the row, return the row. Which meant that for the types where the row carried the correct answer, the correct answer went to the browser.
# what the client asked for GET /activities/a1b2 # what the client got { "question": "...", "options": [...], "correctIndex": 2 }
Nobody reported this. There was no incident, no support ticket, no red dashboard. The feature worked: learners answered questions, scores came back, the numbers looked plausible. The only way to find it was to open the network tab and read a response, and nobody had a reason to.
Nobody had forgotten to strip the field, because there was no shared place where stripping it was the rule. Thirteen independent serializers meant thirteen independent chances to get it right, unassisted, and the base rate for that is not good.
## One table, one interface
The migration collapsed all thirteen models into one Activity row: an enum for the type, a JSON column for the config, and nothing else that varies. The JSON column is the part everyone notices and it is the least interesting decision in the whole change. Postgres has had a good JSON type for years and Prisma maps it fine.
The interesting decision is what replaced the switch statement. One interface, thirteen implementations, and a map from the enum to the implementation:
interface ActivityHandler<Server, Client, Submission> { toClientConfig(config: Server): Client; score(config: Server, submission: Submission): ScoreResult; } export const handlers: Record<ActivityType, ActivityHandler> = { [ActivityType.QUIZ]: new QuizHandler(), [ActivityType.POLL]: new PollHandler(), [ActivityType.DRAG_ORDER]: new DragOrderHandler(), // ... ten more };
The controller no longer knows what a quiz is. It looks up a handler and calls two methods. A new activity type is one class and one entry in that map, and the compiler refuses to build until the map is exhaustive, because Record<ActivityType, …> is not satisfied by twelve of thirteen keys.
## Three schemas, not one
A JSON column has no shape, so every type needed a Zod schema to validate what goes into it. One schema per type would have been enough to make the column safe to write to. I wrote three, and registered them together, and that is the part that fixed the leak rather than the interface above it:
// server config: what is stored. holds the answer. const quizServer = z.object({ question: z.string(), options: z.array(z.string()).min(2), correctIndex: z.number().int(), }); // client config: what is sent. no answer, by construction. const quizClient = quizServer.omit({ correctIndex: true }); // submission: what comes back. const quizSubmission = z.object({ selectedIndex: z.number().int() });
Because toClientConfig is typed as returning the client type, and the client type is derived from the server type by an explicit omit, there is no version of that method that compiles and also returns the answer. Stripping the field stopped being something anyone has to remember, or review for, or write a lint rule about, because getting it wrong now fails the build the same way a typo does.
None of that has anything to do with how many tables there are. Thirteen tables behind one shared handler interface would have closed the leak just as well. Collapsing them is only what made writing the interface obvious enough that we got around to it.
## Do not copy the JSON column
The part of this that gets repeated back to me is the schemaless column, and it is the part I would argue against. A JSON config gives up column constraints, foreign keys and any hope of the query planner helping you, and you buy that back with validation code you now own forever.
It was right here for one narrow reason: the thirteen shapes had genuinely nothing in common, and every query was fetch this one activity by id. The first time somebody needs to filter across configs, that column is a bad day.
Copy the three schemas instead. The split between what is stored and what is sent exists in nearly every application, and almost everywhere it lives in a developer's head as a habit of remembering to delete fields before returning them.
Writing it down as a type costs one omit call and turns a recurring judgement call into something the build enforces once. It works exactly as well with thirteen tables as with one.
The exhaustive Record<Enum, Handler> is worth stealing for the same reason and costs even less. It turns we added a type and never wired it up into a failed build, which is the same trick aimed at a different kind of forgetting.
The habit I took away is narrower than any of that. When the same field has to be removed in more than one place, the removal is the wrong mechanism. Find the type that can express its absence, and let the compiler do the remembering.