0013 — The stack choices, including the four nobody wrote down
Drizzle, Better Auth, Server Actions and Inngest. What the code proves about each, and where the honest answer is that the reasoning was never recorded.
This page is different from the twelve before it. Those record decisions whose reasoning was argued out in the codebase and is quoted back. Four of the five here have no recorded rationale anywhere — no comparison, no rejected alternative, not even a mention of the thing not chosen. What follows separates what the code proves from what would be a story invented after the fact.
Why this page is shaped this way
An audit of the codebase for architectural rationale found roughly two dozen decisions with substantial recorded reasoning, often naming the specific bug that motivated them — and then found nothing at all for four of the most obvious questions a buyer asks.
Searching the entire repository for prisma returns one hit, and it is about an
error message. clerk, nextauth, next-auth, trpc and graphql return
zero hits each. The alternatives were never discussed in writing.
The temptation is to write the justification now and let it read as history. That is the failure this project keeps catching in its own work — a confident claim nobody checked — so instead each entry below says which of these it is:
- Constrained — the code demonstrably depends on the choice, and that dependency is verifiable today
- Unrecorded — the reasoning was never written down, and this page will not invent one
Drizzle rather than Prisma
Constrained, partly.
No comparison was ever written. What is verifiable is that the tenancy design (0002) depends on two things an ORM must permit:
- Raw
set_config(...)executed inside the same transaction as the query it scopes. This is the entire RLS mechanism. - Hand-written SQL migrations, so a table's row-level security policy ships in the same migration as the table.
An ORM that owns migration generation and discourages raw SQL in transactions
would make the central design decision of this codebase awkward at best. That is
a real constraint and it is checkable by reading src/db/tenant.ts.
What this page will not claim is that this was the reason at the time. It is a reason that holds now.
Better Auth rather than NextAuth or Clerk
Unrecorded for the choice; the boundary around it is documented.
Nothing anywhere compares these. What is argued, in src/lib/auth/server.ts, is
the line drawn around whatever library sits there:
Better Auth owns identity and sessions. It does NOT own tenancy — organizations and members are application tables so that the RLS design is not hostage to a plugin's schema decisions.
So the recorded decision is not "Better Auth over Clerk" but "the auth library does not get to own the tenancy schema", and Better Auth's own organization plugin was rejected on exactly that ground. That is the load-bearing choice, and it would apply equally to a different library.
A hosted identity provider would sit awkwardly with a self-hosted, source-licensed template — but no one wrote that down, and it is inference, not history.
Server Actions rather than tRPC
Unrecorded for the choice; the exceptions are documented precisely.
tRPC is never mentioned. What is written down is the narrow list of places a route handler is permitted, each justified in the file:
- Stripe's webhook — a third party has to POST and cannot call a Server Action
- AI chat — streaming is a
Responseconcern; actions resolve once, and forcing a stream through one means reaching for experimental streamable values
And the shape actions return is argued (0004): a discriminated union rather than a thrown error, because an action's failure is usually something the UI should render.
So the codebase has strong opinions about how actions behave and no recorded opinion about the alternative to them.
Inngest rather than pg-boss, BullMQ or a cron
Unrecorded.
No comparison exists. The one near-rationale is ergonomic rather than
architectural: Inngest works with no keys at all against npx inngest-cli dev,
which is why an unconfigured Inngest is a normal way to run the app
(0012).
What is heavily argued is the wrapper. Calling inngest.send directly is a lint
error, because "a job's payload is the one interface you cannot typecheck across
the queue — the sender and the handler run in different processes, minutes
apart." A handler starts with no tenant, runs under withSystem, and must
re-scope explicitly with withTenant.
There is also a real incident, though it is about serving rather than choosing:
/api/inngest was not in the public path list, so Inngest could never have
invoked a single job — the welcome email enqueued at signup sat in a queue
nothing was allowed to drain.
TypeScript pinned to 5.9.3
Recorded, with an expiry condition — the one entry here that is properly documented:
TypeScript 7 is out, but
typescript-eslintstill caps at<6.1.0, and the enforcement layer depends on typed linting.
Worth including as the counter-example. It names the blocker, and it names what would have to change for the decision to be revisited. Every entry above should eventually look like this.
What to do with this page
If you own this codebase and the actual reasons differ from what is inferred above, replace the inference. A wrong reason recorded confidently is worse than an admitted gap, which is why the gaps are admitted rather than filled.
0012 — Integrations are seams, and stay unconfigured until they earn it
Unconfigured is a supported state that fails at the call site with the variable's name — not at build time, and not with an opaque 401.
The design system
Three component layers, five structural themes, and one place that formats a date — so two products built on this template do not look like the same product.