Build a SaaS platform in 2026: the architecture decisions that cost the most to reverse
Every guide to building a SaaS platform hands you a stack. The stack is the cheapest thing to change later. The decisions that hurt are the ones baked into every row, URL and integration, and by month eighteen reversing them is a migration with a customer communications plan. Here they are, ranked by what they cost to undo.
Search for how to build a SaaS platform and page one hands you a stack. A frontend framework, a backend framework, a database, a cloud, and sometimes a diagram with twelve boxes and an API gateway. Pick these, the guides say, and you are building a SaaS platform.
The stack is the least interesting decision you will make. Most of it can be changed later at a cost you can estimate in advance. The decisions that hurt are a different kind: they get baked into every row you store, every URL a customer bookmarks and every integration somebody else builds against you. By month eighteen, changing one of them is not a refactor. It is a migration with a customer communications plan.
I have been brought in to reverse enough of these to have firm opinions about which ones matter. This is that list, sorted by the only question worth asking up front: what does this cost to change once three hundred paying customers depend on it?
Doors that close behind you
Amazon popularised the split between one-way and two-way doors: decisions you can walk back through, and decisions you cannot. It is the right frame for SaaS architecture with one correction. Reversibility is not a fixed property of a decision. It decays, and three things close the door:
- Stored data. Once a year of rows has been written in a shape, changing the shape means rewriting the rows, and some shapes destroy information on the way in.
- Outside dependents. URLs in emails, IDs in a customer's CRM, field names in somebody's integration code, clauses in an enterprise contract. You cannot refactor other people's systems.
- Spread. A decision repeated in four hundred places costs four hundred edits to reverse, plus the ones you miss.
A stack choice mostly suffers from spread. The decisions below suffer from all three, which is why they deserve a day of thought each when everything else deserves an hour.
| Decision | Cost to change in month one | Cost to change in month eighteen | What closes the door |
|---|---|---|---|
| What a tenant is | An afternoon | A quarter, plus a migration window per customer | Every row, cache key and job carries it |
| How users relate to accounts | An afternoon | Weeks, through auth, billing and every permission check | The first customer who needs two workspaces |
| Identifier format | Minutes | Every foreign key, URL and customer integration | IDs printed in emails, exports and other people's databases |
| How money and time are stored | Minutes | Often impossible: the precision is already gone | Floats and naive timestamps lose information on write |
| Which history you record | Free | Impossible: you cannot backfill events nobody wrote down | Time itself |
| Public API and webhook shape | Free | Versioning, deprecation notices, two code paths for years | Customers' code |
| Plans versus entitlements | An hour | Weeks of hunting plan names in conditionals | Spread |
| Where the data lives | A config value | A second deployment and a contract renegotiation | Data processing agreements |
| Frontend framework | A day | A rewrite you can do screen by screen | Spread, but no data |
| Hosting provider | A day | Weeks if containerised, months if not | Proprietary managed services |
| Monolith or services | Free | Splitting is weeks; merging chatty services is worse | Network boundaries |
Read it as two lists. The top eight are decisions about data and contracts. The bottom three are decisions about code. Code can be rewritten. A column of floats that has been summing invoice totals for a year cannot be un-rounded.
1. What a tenant is
The schema argument (shared tables, schema per tenant, database per tenant) gets all the attention and is settled in one table. The more expensive question comes before it: what is the unit? A company, a workspace, a project, a location? Can one sit inside another, the way an agency manages client accounts? Every query, job, cache key, file path and invoice will be scoped to whatever you pick.
The most expensive reversal I see is not switching schema models. It is adding tenancy to a product that was built for one company and then sold to a second. Every table gains a column, every query gains a predicate, every background job gains an argument, and every one you miss is a cross-tenant leak. Put a tenant_id on every tenant-owned table in the first migration, enforce it in the data layer, and do it even while there is exactly one tenant. It costs nothing on day one.
2. Users are not accounts
The single most common regret in the SaaS codebases I review is one column: users.account_id. It says a person belongs to exactly one account, forever. Then a consultant needs access to two clients, a founder wants a test workspace, an agency manages twenty customers, and the same email address cannot sign up twice.
-- The shortcut: a user belongs to exactly one account, forever.
CREATE TABLE users (
id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts(id),
email text UNIQUE NOT NULL,
role text NOT NULL
);
-- The shape that survives: an identity, and separately, memberships.
CREATE TABLE users (
id uuid PRIMARY KEY,
email text UNIQUE NOT NULL
);
CREATE TABLE memberships (
account_id uuid NOT NULL REFERENCES accounts(id),
user_id uuid NOT NULL REFERENCES users(id),
role text NOT NULL,
PRIMARY KEY (account_id, user_id)
);Note where role went. A person is an admin of one account and a viewer in another, so the role belongs to the membership, never the user. Billing attaches to the account, never the person. And when an enterprise customer asks for SSO, it will be their identity provider for their account, which only makes sense if identity and membership were separate to begin with. Doing this on day one costs one extra table and an account switcher you do not have to build yet.
3. Identifiers you will see in other people's systems
Auto-incrementing integers are fine inside a database and a liability outside it. Invoice 1043 tells a competitor how many invoices you have issued. /projects/1044 invites someone to try 1045, and your authorisation is then the only thing standing between them and another customer's project. And integer keys collide the moment you merge two databases, import a customer's data or split a tenant out onto its own instance.
Use UUIDv7 primary keys, which are time-ordered and therefore kind to B-tree indexes in a way random v4 UUIDs are not. Postgres 18 generates them natively with uuidv7(). If you want something friendlier in URLs and support tickets, put an opaque prefixed public ID such as inv_01J9... in front of the internal key. What you must not do is expose integers now and plan to change it later, because by then those IDs are in customers' spreadsheets, Zapier steps and webhook consumers.
4. Money and time, stored so they cannot rot
These are cheap to get right and among the few mistakes that can be genuinely irreversible, because the wrong storage destroys information as it writes. A float cannot represent 0.10 exactly; sum enough of them and the totals drift by cents you cannot reconstruct. A timestamp without a time zone records 09:00 and forgets whose 09:00 it was.
CREATE TABLE invoice_lines (
id uuid PRIMARY KEY DEFAULT uuidv7(),
invoice_id uuid NOT NULL REFERENCES invoices(id),
amount_minor bigint NOT NULL, -- 1999 means 19.99
currency char(3) NOT NULL, -- ISO 4217. JPY has no minor unit, KWD has three
tax_rate_bp integer NOT NULL, -- the rate applied at the time, in basis points
created_at timestamptz NOT NULL DEFAULT now()
);Money as an integer in minor units with its currency beside it. Every instant as timestamptz, stored in UTC, with the user's or tenant's time zone kept separately for display. Calendar facts such as a due date stored as a date, because a due date is not an instant. And the tax rate written onto the line at the moment it applied, not looked up later, because rates change and an invoice from last year must still add up.
5. The history you did not record
This is the one door that is not just expensive but welded shut. The first enterprise security questionnaire will ask who changed a record and when. The first billing dispute will ask what the customer's plan was on 3 March. If you did not write it down at the time, no migration will recover it.
An append-only audit table and a listener that writes to it costs about a day. Record the tenant, the action, the subject, what changed, when, and the actor, with the actor's type as a column of its own: a user, a support engineer impersonating one, an API key, a scheduled job, or, increasingly, an AI agent acting on someone's behalf. That last one is no longer hypothetical, and the first question anyone asks after an agent does something surprising is which human it was acting for. Keep the raw payload of every inbound webhook from your payment provider too. It is the only record of what they told you.
6. The contract other people build against
The moment a customer writes code against your API or consumes your webhooks, field names stop being yours to change. You can still evolve, but through versioning, deprecation notices and old code paths you keep running for years. The cheap moves all happen before the first integration:
- Version from the first endpoint, in the path or a header, even if the API is private today. Retrofitting a version onto an unversioned API is itself a breaking change.
- Serialise through explicit resources, never by dumping models. Otherwise renaming a database column renames a public field without anyone noticing.
- Give every webhook an envelope: an event id for idempotency, a type, a timestamp, the API version it was rendered with, and a signature. Consumers will rely on all five.
{
"id": "evt_01J9ZK7Q3M4X8V2B6N1R5T0YHC",
"type": "invoice.paid",
"api_version": "2026-10-01",
"created_at": "2026-10-24T09:14:03Z",
"tenant_id": "ten_01J9ZJ4W2D7F3G8H5K6L9P0QRS",
"data": { "invoice": { "id": "inv_01J9ZK6E8R2T4Y6U8I0O1P3ASD", "amount_minor": 4900, "currency": "GBP" } }
}7. Plans are not features
Every early SaaS codebase checks the plan name: if ($account->plan === 'pro'). By month eighteen that check is in a hundred and forty files, and then three things happen in the same quarter. Sales closes an enterprise deal with one feature from the tier above. Pricing changes, and existing customers have to keep what they bought. A usage limit needs to differ per account. Each of those means finding and editing every conditional.
// Spread across the codebase by month eighteen.
if ($account->plan === 'pro') {
// ...
}
// One question, asked everywhere, answered in one place.
if ($account->entitled('exports.scheduled')) {
// ...
}
final class Entitlements
{
public function for(Account $account): array
{
return array_merge(
config("plans.{$account->plan}.features"), // what the plan includes
$account->feature_overrides ?? [], // what sales promised this customer
);
}
}The code asks about capabilities. Only one class knows which plan grants which. Changing your pricing becomes a config change and a grandfathering decision rather than a search-and-replace across the product. This is also the seam that keeps you from being welded to one billing provider: the provider decides who has paid, your entitlements decide what that buys.
8. Where the data lives
One region is the right answer for almost every product at launch. The cheap insurance is a region column on the tenant with one value in it, and no code that assumes every tenant's data sits in the same database. Then the day a European enterprise buyer requires EU residency, you are deploying a second stack and routing tenants to it, not untangling every query that joined across what are now two continents.
The doors that look heavy and are not
The decisions most guides spend their word count on mostly sit in the cheap half of the table.
- Microservices. Start with a modular monolith: clear module boundaries inside one deployable. Splitting a well-bounded module out later is weeks. Merging a dozen chatty services that should never have been separate is far worse, and you pay their network and operational overhead from day one.
- Frontend framework. Painful to change, but it holds no data and can be replaced one screen at a time. Pick what your team ships fastest in.
- Hosting. Containerise, keep infrastructure in code, and be deliberate about proprietary managed services you cannot run anywhere else. Then moving clouds is a project with a plan, not a rewrite.
- Payment provider. Less locked in than it feels. Stripe will migrate card data to another PCI-compliant processor on request. What actually locks you in is subscription logic scattered through your code, which entitlements fix.
- Kubernetes. You almost certainly do not need it at launch, and a managed platform is easy to leave later. This one is reversible in the direction most people are worried about.
The 2026 addition: decisions about the model
Most new SaaS platforms now have an AI feature from the first release, and those features bring their own one-way doors.
- The embedding model. Every vector in your index belongs to the model that produced it. Changing models means re-embedding the entire corpus, and often a new index if the dimensions differ. Choose with that cost in mind, and keep the source text so re-embedding is a batch job rather than a crisis. The trade-offs are in choosing a vector database.
- Tenant isolation in the index. Retrieval over customer documents is a tenancy boundary with weaker defaults than your database. Filter inside the index, not after it, from the first release.
- What you log. Store prompts, retrieved context and outputs per call, scoped per tenant. Without them you cannot evaluate a change, and you cannot safely move to a cheaper model when one arrives.
- The provider seam. A thin interface between your product and the model API keeps the choice of model a two-way door. That matters because not every call needs the expensive model, and the cheapest tier keeps moving.
A decision lands on your desk
Does it change the shape of stored data?
↳ yes? one-way door. Spend the day on it
Will anyone outside your codebase depend on it?
↳ yes? customers, URLs, webhooks or contracts: version it from day one
Will it be repeated in hundreds of places?
↳ yes? put it behind one function before it spreads
Two-way door
Pick what your team ships fastest in, and move on.
Most decisions fall straight through to the bottom. The few that stop early are the ones worth a whiteboard.
The week-one list
If you are starting a SaaS platform this month, this is what I would have in place before the first feature:
- 1A
tenant_idon every tenant-owned table, set and enforced by the data layer, from the first migration. - 2Users and memberships as separate tables, with the role on the membership.
- 3UUIDv7 primary keys, or opaque public IDs in front of internal integers.
- 4Money as integer minor units with a currency. Instants as
timestamptzin UTC. - 5An append-only audit table with the actor type recorded, and raw payment webhooks kept.
- 6Entitlement checks behind one method, never a plan name in a conditional.
- 7A versioned API, explicit serialisers and signed webhooks with event ids, even while the API is private.
- 8A
regioncolumn on the tenant, with one value in it.
That is three or four days of work in a new codebase, which is noise inside the twelve to twenty weeks a production platform takes to build. If you plan to have AI write most of the code, it matters more rather than less, because an agent will pick a shape for whatever you leave undecided and faithfully repeat it everywhere. What the AI speedup claims leave out covers the rest of that story.
Pick your stack in an afternoon. Spend the week on what a tenant is, who a user is, what an ID looks like and what you write down. Those are the parts you will still be living with after the stack has been replaced.
If you are about to start a SaaS platform, or you are eighteen months in and one of these doors has already closed behind you, I will go through the architecture with you and come back with what to fix now, what to leave alone, and what each change costs. Book a SaaS architecture review, or see the platforms I have shipped on the work page.
Questions people ask
What architecture decisions matter most when building a SaaS platform?
The ones that are hard to reverse: what a tenant is, how users relate to accounts, identifier format, how money and time are stored, which history you record, the public API and webhook shape, plans versus entitlements, and where data lives. These cost minutes or an afternoon in month one and can take a quarter, or become impossible, by month eighteen. Frontend framework, hosting provider and monolith versus services are far cheaper to change later.
Should a new SaaS platform start with microservices?
No. Start with a modular monolith: clear module boundaries inside one deployable. Splitting a well-bounded module out later takes weeks, while merging a dozen chatty services that should never have been separate is far worse, and you pay their network and operational overhead from day one. Kubernetes is similarly unnecessary at launch for almost every product, and a managed platform is easy to leave later.
What should be in place in week one of building a SaaS app?
Before the first feature: a tenant_id on every tenant-owned table enforced by the data layer, separate users and memberships tables, UUIDv7 or opaque public ids, money as integer minor units with a currency, instants as timestamptz in UTC, an append-only audit table recording actor type, entitlement checks behind one method, a versioned API with signed webhooks carrying event ids, and a region column on the tenant. That is three or four days of work.
What architecture decisions does an AI feature add to a SaaS platform?
Four one-way doors. The embedding model, because changing it means re-embedding the whole corpus, so keep the source text. Tenant isolation in the vector index, filtered inside the index rather than after retrieval. Logging prompts, retrieved context and outputs per call, scoped by tenant, so changes can be evaluated. And a thin provider interface between the product and the model API, so the model choice stays reversible. Choosing a vector database covers the index trade-offs.
Building something like this?
I design and ship these systems for clients: retrieval over private data, agents that complete real tasks, and the Laravel platforms underneath them.