Chapter 9 · Foundations

The decisions that are expensive to reverse

Nine choices that are cheap now and expensive in eighteen months, each with the specific migration it forces and the hour at which you will discover it.

7 min read

Most architectural decisions can be revisited. These nine cannot be, cheaply, and they are all made in the first week — usually without being noticed as decisions at all.

They are gathered here so they can be made deliberately once rather than defaulted into nine times.

1. Primary key type

Cheap now: uuid instead of bigserial.

Expensive later: every foreign key rewritten, and every id already stored outside your database — in the payment processor's metadata, in support tickets, in a customer's own integration — now points at nothing. The migration is not hard, it is unbounded, because you do not control everywhere your ids went.

2. One login per person, or one login per tenant

Cheap now: users + memberships, from Chapter 8.

Expensive later: merging duplicate accounts belonging to the same human, deciding which of their two password hashes survives, and emailing people to tell them their login changed. This is the one that generates support volume rather than engineering time, which makes it worse.

3. Whether the tenant is in the URL

Cheap now: decide between /t/:slug/..., :slug.app.com, and neither.

Expensive later: every link in every email ever sent, every bookmark, and every page people have shared internally. "Neither" — resolving the tenant purely from the session — is the one that looks cleanest and breaks worst, because a user with two memberships cannot open two tenants in two tabs. Chapter 15 is the full comparison.

4. Who collects the money

Cheap now: Chapter 5.

Expensive later: every existing tenant must complete identity verification before they can transact again, statement descriptors change for every future charge, and dispute history does not transfer. This is the most expensive item on the list and the one most often decided by accident — by writing a charge with the platform's own key because that is what the quickstart shows.

5. Whether tenant_id is denormalised onto child tables

Cheap now: put it on every table, including ones reachable through a parent.

Expensive later: adding it means backfilling from parents via joins, on tables that are by then the largest you have, and every RLS policy written against the join must be rewritten against the column. Chapter 25 makes the full argument.

-- The backfill you avoid by doing it on day one. On a large invoices table -- this is a long-running update that locks rows other requests want. update invoices i set tenant_id = s.tenant_id from subscriptions s where i.subscription_id = s.id and i.tenant_id is null;

6. Row-level security on or off

Cheap now: policies on every table from the first migration.

Expensive later: writing policies against a schema full of production data and code that was never written to satisfy them. Every policy becomes a guess at what the application currently depends on, and the way you find out you guessed wrong is that a page breaks in production for one tenant.

7. Soft or hard deletion

Cheap now: deleted_at on tenants; hard deletion on genuinely disposable child rows.

Expensive later: both directions hurt. Having hard-deleted, you cannot produce rows a retention obligation or a billing dispute requires. Having soft-deleted everything, every query carries and deleted_at is null forever and the one place you forget it shows deleted data to a live user.

8. Timestamp type and timezone policy

Cheap now: timestamptz everywhere, UTC in storage, tenant timezone as display only.

Expensive later: ⚠️ this one is quietly the worst on the list. timestamp without time zone columns holding a mixture of UTC and local values cannot be repaired automatically, because nothing in the data records which is which. The discovery moment is a daylight-saving boundary, and the symptom is a billing period that is an hour wrong for some tenants.

-- Right. created_at timestamptz not null default now() -- Wrong, and unrepairable once it holds a year of mixed values. created_at timestamp not null default now()

9. Where platform staff identity lives

Cheap now: a separate table, from Chapter 6.

Expensive later: if platform access is a role inside memberships, then every permission check in the product is also a potential privilege escalation into every tenant, and separating them means auditing every check you have written.

The summary table

#DecisionDiscovered atReversal cost
1Key typeFirst external integrationUnbounded — ids escaped your system
2Login modelFirst dual-tenant userSupport volume, merged accounts
3Tenant in the URLFirst shared linkEvery email and bookmark
4Who collects moneyFirst compliance reviewRe-verify every tenant
5Denormalised tenant_idFirst large tableLocking backfill + policy rewrite
6RLSFirst isolation bugPolicies written as guesses
7Deletion modelFirst retention requestRows already gone, or a null check forgotten
8Timestamp typeA DST boundaryUnrepairable
9Platform identityFirst security reviewAudit every permission check

The pattern is that none of these feels like a decision when it is made. Eight of the nine have a default that is chosen by not thinking, and in seven cases the default is the expensive one.

How to actually use this

Write the nine answers into the repository before the first migration, as a file next to the schema. Not because documentation prevents mistakes, but because the next engineer's instinct will be to add users.tenant_id — it is the obvious shape — and a file that says why not, in two sentences, is cheaper than the conversation.

The items most often got wrong in practice, in order: 8 (timestamps, because timestamp is shorter to type), 2 (users.tenant_id, because it is the obvious model), and 4 (platform collects, because it is what the quickstart shows).

Key points

  • Nine first-week decisions are expensive to reverse, and most are made by default rather than by choice.
  • uuid keys, because ids escape into systems you do not control.
  • One login per person with a membership table. Reversing this costs support volume, not engineering time.
  • Decide the tenant's place in the URL before any link is shared. Session-only resolution breaks two tabs.
  • Who collects the money is the costliest item: reversing it re-verifies every tenant and changes every statement descriptor.
  • Denormalise tenant_id onto child tables now, or backfill the largest tables later with a locking update and rewrite every policy.
  • timestamptz, always. Mixed-value timestamp columns are unrepairable because the data does not record which values were local.
  • Platform staff identity belongs in its own table, or every permission check becomes a possible escalation.
  • Write the nine answers into the repo beside the schema, so the next engineer meets the reasoning before the instinct.

Next: How this series is verified — the test harness every later number leans on.