Home · About · Services · Industries · Projects · Products · Process · Blog · FAQ · Contact
SaaS MVP architecture: early decisions that prevent a rewrite The few architecture decisions a SaaS MVP must get right on day one, including tenancy, auth, billing and background jobs, and the ones you can safely postpone.
Picture a SaaS MVP with twenty paying customers and a familiar set of problems. Every customer's data sits in the same tables with no tenant column, so a report for one account occasionally shows rows from another. Billing logic is spread across six controllers. Background emails run inside web requests and time out. The team is discussing a rewrite, eight months after launch.
None of those problems came from moving fast. They came from a handful of early decisions that were cheap to get right on day one and expensive to change later. This post covers those decisions, and just as importantly, the ones you can safely put off.
The short answer Good SaaS MVP architecture is mostly about avoiding a rewrite. That means starting as a modular monolith on PostgreSQL, putting a tenant ID on every customer-owned row from the first migration, using a proven auth provider or library, treating billing as webhook-driven state, and moving slow work to a background queue. Almost everything else can be deferred without much regret.
That list is short on purpose. Microservices, Kubernetes, event sourcing and multi-region hosting are all solutions to problems an MVP does not have yet. What it does need is a shape that lets you add features, customers and engineers without unpicking the foundations.
Start with a modular monolith One codebase, one deployable, one database. Inside it, organise code by business area rather than by technical layer: accounts, billing, projects, notifications. Each module owns its tables and exposes a small set of functions or service classes that other modules call. Modules do not reach into each other's tables directly.
This gives you most of the benefit people want from microservices, clear ownership and boundaries, without network calls, distributed transactions or several pipelines to maintain. If one area later needs to scale or deploy separately, a module with a clean interface is a far easier thing to extract than a tangle of shared models.
Pick a mainstream framework your team already knows well: Django, FastAPI, Rails, Laravel, NestJS or similar. The framework matters less than familiarity. Keep one repository. A monorepo with the backend, frontend and infrastructure config is simpler to version and review at this stage. Add a lint or test rule that stops modules importing another module's internals, so the boundaries survive deadline weeks. Build one container image per release and promote the same image from staging to production. Decide tenancy on day one Multi-tenancy is the decision that is hardest to retrofit. There are three common models, and for almost every B2B MVP we recommend the first.
Shared tables with a tenant_id column. One schema, every customer-owned row tagged. Cheapest to run, simplest to migrate, and it scales to thousands of tenants. Schema per tenant. Better isolation, but every migration runs once per tenant, and connection pooling and reporting get harder as the count grows. Database per tenant. Strongest isolation and easy per-customer restore, at the highest operational cost. Usually reserved for a few large customers who require it contractually. With shared tables, the risk is a query that forgets its tenant filter. Application-level scoping helps, but we like a second line of defence in the database itself. PostgreSQL row-level security can enforce the filter even when the application code gets it wrong. Our post on multi-tenant SaaS database design covers composite keys and the trade-offs in more depth; the essentials look like this.
CREATE TABLE projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id),
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX projects_tenant_created ON projects (tenant_id, created_at);
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);The application sets the tenant at the start of each transaction. Using set_config with the third argument true makes the value local to that transaction, so it cannot leak to the next request that reuses the pooled connection.
from sqlalchemy import select, text
from sqlalchemy.orm import Session
with Session(engine) as session, session.begin():
session.execute(
text("SELECT set_config('app.tenant_id', :tid, true)"),
{"tid": str(current_tenant_id)},
)
projects = session.scalars(select(Project)).all()Two details catch people out. Superusers and roles with BYPASSRLS ignore policies entirely, so the application must connect as an ordinary role. And without FORCE ROW LEVEL SECURITY, the table owner bypasses the policy too. Run migrations as the owner and the application as a separate, less privileged role. Because FORCE applies to the owner as well, data backfills that touch every tenant need a role with BYPASSRLS or a loop that sets each tenant in turn.
Identity, roles and billing Authentication is not where an MVP should be original. Use a well-maintained library or a hosted identity provider, and design the data model around it from the start:
Users and tenants are separate entities joined by a membership table with a role. One person will eventually belong to two organisations, and this model handles it without a migration. Keep roles coarse at first, such as owner, admin and member, checked in one place. Fine-grained permissions can grow from that single function. Plan for SSO even if you do not build it yet. Enterprise buyers ask for SAML or OIDC early, and a hosted provider usually makes it a configuration change. Record an audit log of sign-ins and admin actions from launch. It costs little and answers support questions you will certainly get. For billing, let the payment provider own the subscription and treat its webhooks as the source of truth. Store the provider's customer and subscription IDs on the tenant, and derive plan limits from a local table that is updated only by webhook handlers. Webhooks can arrive more than once and out of order, so record each event ID and skip ones you have already processed. Then put every feature check behind one function, such as can(tenant, 'export_pdf'), rather than scattering plan names through the code.
Background jobs, files and email Anything slow or unreliable should leave the request cycle: sending email, generating PDFs, calling third-party APIs, importing CSVs. Add a job queue before launch, not after the first timeout. Celery or RQ with Redis, BullMQ for Node, or a PostgreSQL-backed queue if you want one less service to run. Make every job safe to retry, because it will be retried.
Store uploaded files in object storage, S3 or a compatible service such as MinIO, never on the application server's disk. Key the objects by tenant, serve them through short-lived signed URLs, and you can move between servers or providers without a data migration. Send email through a transactional provider with SPF, DKIM and DMARC set up on your domain before the first invite goes out.
Deployment, data and observability The infrastructure for an MVP can be modest. A containerised app on one or two servers or a managed platform, a managed or well-run PostgreSQL instance, and a CI pipeline that tests, builds and deploys on merge. What matters more than the hosting choice is a few habits:
Three environments at minimum: local, staging and production, configured through environment variables rather than code changes. Schema migrations in version control, run automatically on deploy, written to be backward compatible with the previous release. Automated database backups with a restore you have actually tested, and a written note of how long it took. Structured logs carrying tenant_id and a request ID, error tracking, and uptime checks on the main user journeys. Feature flags, even a simple table, so unfinished work can ship to production switched off. Add a public API boundary early, even if only your own frontend uses it. Version it from the first endpoint, for example under /api/v1, so later changes do not break mobile apps or integrations you have not built yet.
What you can safely postpone Knowing what to skip is half of the job. These are worth leaving until a real need appears:
Microservices and Kubernetes. A modular monolith on one or two servers carries most MVPs well past their first hundred customers. Read replicas, sharding and caching layers, until monitoring shows a specific slow query or hot path. Multi-region hosting, until a customer contract or a measured latency problem requires it. A custom admin panel. Your framework's admin or a simple internal tool covers early support work. Usage-based billing, until you have usage data that shows how customers actually consume the product. A checklist before you write feature code Modular monolith layout agreed, with module boundaries written down. tenant_id on every customer-owned table, indexed, with row-level security or an equivalent enforced filter. Users, tenants and memberships modelled separately, with roles checked in one place. Billing state driven by idempotent webhook handlers and a single entitlement check. Job queue, object storage and transactional email in place. CI pipeline, staging environment, migrations on deploy and a tested restore. Logs, errors and uptime monitoring that include the tenant. If most of these are in place, adding customers and features is ordinary work. If several are missing in a product that already has paying users, fix tenancy and billing first, since those carry the most risk, and bring in an outside review before the gaps decide the rewrite for you.
Infigini IT Services · 11 Kanwali Road, Dehradun 248001, India · info@infigini.com · +91 95684 87789