launch-quickly
Decisions

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:

  1. Raw set_config(...) executed inside the same transaction as the query it scopes. This is the entire RLS mechanism.
  2. 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 Response concern; 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-eslint still 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.

On this page