Design Philosophy
Design Philosophy
Warpkit is a B2C SaaS starter. It is not an enterprise architecture showcase. Every decision optimizes for one thing: getting a working, trustworthy product in front of paying customers as fast as possible. This fork (warpkit-postgres) keeps that same goal, aimed at a different deploy target , see below.
This page explains the core tradeoffs, what was chosen and why, and what this fork explicitly is not trying to be.
The central bet: shared Postgres, for serverless/edge deploy
The base warpkit template's central bet is per-user SQLite , one file per user, isolation by construction. That's still the right default for a self-hosted, single-region deploy. This fork trades that for a single shared Postgres database instead, because that's what a serverless runtime like Cloudflare Workers requires: no persistent filesystem to hold per-user files, no long-lived process to hold a per-user connection cache. See architecture/postgres-port.md for the full comparison and migration shape , this page focuses on the reasoning, not the mechanics.
Shared Postgres (one database)
users, sessions, subscriptions ← always shared, both templates
notes, uploads, api_keys, ... ← user-scoped, filtered by user_id
(per-user files in the base template)
Why this works:
- Serverless-compatible. No local disk, no per-user connection pool to warm , the same shared Postgres pool (proxied through Hyperdrive on Workers) serves every request regardless of which isolate handles it.
- Cross-user queries are native SQL. Admin aggregates, analytics, and anything spanning users is a normal query against one database instead of a fan-out across per-user files or a separate read model.
- Standard ops tooling. Any Postgres client, any managed Postgres host (Supabase, Neon, RDS, self-hosted), standard connection pooling , no per-user file lifecycle to manage.
The tradeoff:
- Tenant isolation is no longer structural , see
postgres-port.mdfor the full statement of what that costs and why it was accepted anyway. - Write concurrency per user goes through
withUserTransaction, a real Postgres transaction, not a process-local mutex , see that doc for the isolation-level caveat (read-committed, not a serialized lock). - Running Postgres is a real operational dependency the base template doesn't have. Fine at any scale this fork targets; a self-hosted single-VPS deploy with no external DB to run is a real advantage the base template keeps that this fork gives up.
Type-safe RPC with zero ceremony
Unchanged from the base template. Warpkit uses TanStack Start's createServerFn instead of REST controllers or GraphQL resolvers.
export const createItem = createServerFn({ method: 'POST' })
.inputValidator(z.object({ title: z.string().min(1).max(200) }))
.handler(async ({ data }) => {
const user = await requireUser();
if (!user) return err('UNAUTHORIZED', 'Not authenticated');
return withUserTransaction(user.id, async (tx, userId) => {
await tx.insert(items).values({ id: uuidv7(), userId, title: data.title });
return ok({ id, title: data.title });
});
});
No controllers. No DTOs. No dependency injection tokens. No decorators. The function is the endpoint. TypeScript catches mismatches between client call sites and server handlers at compile time.
Real databases in tests
// src/test/db.ts
export const withTestDb = <T>(fn: (tx: UserDbTx) => Promise<T>): Promise<T> =>
// transaction-per-test against a real Postgres instance, forced rollback
// via a sentinel thrown inside db.transaction , see the file's own
// comments for why a naive BEGIN/ROLLBACK on a pooled connection
// wouldn't work here.
Tests run against a real, dockerized Postgres instance (docker-compose.yml) with real migrations applied, using a transaction-per-test pattern rather than in-memory SQLite. No mocks for the database layer.
This catches real bugs: schema mismatches, migration ordering issues, constraint violations, query correctness, and , specific to this fork's tenant model , cross-tenant leaks from a missing user_id filter, exercised directly by regression tests on every ported feature. Mock-based database tests pass when the real query is broken. Warpkit tests do not.
Durability where incidents happen
Unchanged in spirit from the base template; the mechanics moved with the DB port.
Account deletion (src/lib/operations/account-deletion.server.ts) is a multi-step operation spanning Stripe, S3, and the shared database. If the server crashes between steps, the startup reconciler picks up where it left off. Steps are idempotent. A lease prevents duplicate execution. The old "delete the per-user SQLite file" step is now a no-op marker , cleanup happens automatically via onDelete: 'cascade' on every user-scoped table's foreign key to users.id, exercised when the final step deletes the shared user row.
Billing reconciliation (src/lib/operations/billing-reconciliation.server.ts) runs at startup on self-hosted (wired in server/start.ts) and on a daily schedule via the jobs system on Cloudflare Workers (billing:reconcile, see docs/warpkit/features/jobs.md) , syncs every subscription row against Stripe's current state. Stripe is the source of truth. Webhooks are the fast path; the reconciler is the safety net for missed events.
These are not architectural ceremony. They exist because "server crashed mid-deletion" and "missed webhook left a canceled user with active access" are real production incidents that happen to every SaaS product eventually, on either template.
What this fork is not
Not an enterprise architecture. There are no bounded contexts, no CQRS, no event sourcing, no aggregate roots, no domain rules objects. Business logic lives as functions. For a one- or two-developer SaaS at early scale, named domain rules and command/query separation add far more overhead than value.
Not RLS-enforced. Postgres Row Level Security is the standard way to enforce tenant isolation at the database layer on shared Postgres. This fork deliberately uses app-level user_id filtering instead, consistent with how every query in the codebase already works , see postgres-port.md for the reasoning. Not rejected for a technical reason; just not the path taken here.
Not a learning exercise. The goal is not to demonstrate patterns. The goal is to ship a product buyers trust. Every abstraction in the codebase exists because removing it would cause a real problem, not because it follows a methodology.
When to grow beyond this fork's patterns
The cliff is real. At some point, as a product grows, you'll want:
- Named domain invariants when business rules get complex enough to have edge cases worth naming
- Durable event delivery when fire-and-forget in-process events aren't safe enough for critical flows
- A CI-enforced check for
user_idscoping (documented TODO, not built) if app-level filtering discipline starts slipping , or, at that point, revisit RLS - A read/write split, connection-pool tuning, or table partitioning when query complexity or write volume outgrows a single shared Postgres instance
- Horizontal scaling of the app tier itself, independent of the DB question , the Cloudflare Workers target already scales this way by default; self-hosted has
cluster.tsfor per-host multi-process scaling
The right time to add these is when the problem actually exists, not before. Most B2C SaaS products fail before they reach the scale where these patterns become necessary. Ship first. Refactor when complexity demands it.
The metric this fork optimizes for
Time from git clone to first paying customer, on a deploy target that doesn't require managing a server.
Everything else is secondary.