The multi-tenancy problem in most SaaS applications is a data isolation problem. You have many customers, they all share the same code, and you need to make sure one customer's data never leaks into another's view. There are well-understood patterns for this — row-level security, per-tenant database schemas, full per-tenant databases — and the choice among them is mostly about cost and operational complexity.
In POS SaaS, multi-tenancy is a different kind of problem. Data isolation matters, but it's the easy part. The hard part is that every tenant has different payment processors, different fee structures, different compliance requirements, different regional regulations, different settlement schedules, different chargeback policies, and different definitions of what a "successful" transaction looks like. Your platform has to accommodate all of this variation without forking into a separate codebase per tenant, and the architectural techniques for managing that variation are very different from the techniques for preventing data leakage.
What "tenant" even means
The first clarification: in a POS system, there are at least three levels that could be called a tenant, and they behave differently.
The merchant is the business that signed up for your platform. One merchant has one billing relationship with you, one master configuration, usually one legal entity. For a small merchant, this is the whole picture. For a larger merchant, it's the top of a hierarchy.
The location is a physical store or operational unit under a merchant. A restaurant chain has a merchant with many locations. Each location has its own hours, its own menu, its own cash drawer, its own daily sales total — but shares the merchant's account, processing setup, and compliance posture.
The terminal is the specific device running the POS software. A location has one or many terminals. Each terminal has a unique identifier, its own local configuration (receipt printer, card reader type), and its own operational state (logged-in user, open checks).
Data isolation is usually at the merchant level. A merchant's data is never visible to another merchant. But within a merchant, locations can share data in constrained ways, and within a location, terminals share data freely. Your authorization model has to reflect this — a user at Location A might have read access to Location B's sales but not Location C's, while a merchant-level admin sees everything. This is a real hierarchy, not a flat list of tenants.
Data isolation: the shared-schema model
For a POS platform, the right default is a shared-schema model with tenant ID columns everywhere. Every row in every table has a merchant_id (and usually a location_id), and every query is filtered by merchant. The alternatives — schema-per-tenant or database-per-tenant — sound more secure but create operational nightmares at any meaningful scale.
Schema-per-tenant means every deploy has to migrate N schemas. With a thousand merchants, a simple column addition becomes a thousand migrations. If one fails, you have drift, and drift in a payment system is how you get wrong answers to queries that happen to hit the drifted tenant.
Database-per-tenant has the same problem at a coarser granularity. You get stronger isolation at enormous operational cost, and unless your compliance regime specifically requires it (some do, for specific regulated industries), you're paying for guarantees you don't need.
The shared-schema model requires discipline:
- Every table has
merchant_id. No exceptions. Not "metadata" tables, not "reference" tables, not "audit" tables. If it has customer data at all, it hasmerchant_id. - Every query filters on
merchant_id. This is enforced at the framework level. A raw query that doesn't include a merchant filter is a bug, and it's the kind of bug that produces data leakage. - Row-level security as a backstop. Postgres RLS policies that enforce tenant isolation at the database level, so that even a bug in application code that forgets the filter is caught by the database.
ALTER TABLE transactions ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON transactions
USING (merchant_id = current_setting('app.merchant_id')::uuid);
The application sets the session-local app.merchant_id at the start of each request, and all subsequent queries are implicitly filtered. This is the single most valuable safeguard against accidental data leakage, and it costs almost nothing at runtime.
Per-tenant processor configuration
Every merchant has their own processor setup. Some have one processor. Some have several, routed by geography or payment method. Some use the platform's default processor for most transactions and a merchant-specific one for specific cases (e.g., corporate cards, specific banks).
This is configuration, not code. A processor-configuration table maps merchants to their processor instances:
CREATE TABLE merchant_processors (
merchant_id UUID NOT NULL REFERENCES merchants,
processor_type TEXT NOT NULL, -- 'stripe', 'adyen', 'worldpay', etc.
processor_config JSONB NOT NULL, -- encrypted credentials, account IDs, etc.
priority INT NOT NULL, -- routing priority
conditions JSONB, -- when to use this processor
enabled BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY (merchant_id, processor_type)
);
The routing logic reads this table per transaction. Which processor handles a given transaction depends on the merchant, the amount, the currency, the payment method, the card brand, the time of day, the cart contents — whatever conditions the merchant has configured.
The challenge is that processor credentials are sensitive. They go into scope for PCI and for key management generally. Encrypting the credentials at rest is table stakes; beyond that, the system needs to track credential rotation, support multiple active credentials per processor (for blue/green rotation), and audit access to the decrypted form. The configuration table is one of the most security-critical tables in the system.
Per-tenant fees
Every merchant has a fee structure. Some are simple (2.9% + 30¢ per transaction). Some are complex (interchange-plus with per-card-type adjustments). Some have volume tiers, promotional periods, revenue-share agreements, and interchange adjustments that only the merchant's bank understands.
This cannot be calculated in code. It has to be modeled as data. A fee structure is a set of rules, applied in order, each producing a fee component:
CREATE TABLE fee_rules (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL,
rule_type TEXT NOT NULL, -- 'percentage', 'flat', 'tiered', etc.
rule_spec JSONB NOT NULL,
applies_when JSONB, -- conditions on transaction
priority INT NOT NULL,
effective_from TIMESTAMPTZ NOT NULL,
effective_to TIMESTAMPTZ
);
When a transaction settles, the fee calculation iterates through the rules in priority order, evaluates each one's conditions against the transaction, and accumulates the applicable fees. The resulting fee breakdown is stored per transaction so that it can be audited and reconciled.
Two things matter here. First, fee rules have effective dates — the rules that apply to a transaction are the rules in effect at the time of the transaction, not the current rules. When you change a merchant's fee structure, historical transactions keep their old fees. Second, the rules are data-driven enough that a new rule type is a code change but a new rule using existing types is just a row insert. This is the boundary of what needs engineering work and what's a configuration task.
The noisy neighbor problem
In a shared-infrastructure model, one tenant's spike can affect everyone else. This is acute in POS systems because traffic is bursty — lunch rush at noon, closing time, Black Friday, concert venue events. A single merchant with an unusual event can generate more load than your normal steady-state traffic, and if they're co-located with other merchants, those merchants see degradation.
Mitigations have to be thought through carefully:
Per-tenant rate limiting. Every merchant has a request rate budget, usually set much higher than their normal load. Exceeding it produces 429s, not degradation for others. The budget is configured by merchant tier.
Per-tenant resource quotas. CPU and memory limits at the application layer, enforced per merchant for expensive operations (report generation, bulk exports, reconciliation jobs).
Queue isolation. Background work is partitioned by merchant so that one merchant's backlog doesn't delay another's. This usually means merchant-sharded queues or explicit fair scheduling at the consumer.
Database isolation. High-volume merchants may need their own database shard or their own dedicated connection pool, even within a shared schema. The threshold is typically when a single merchant is using more than 10-20% of the database's capacity.
Without these mitigations, every incident looks like a platform-wide issue even when it's caused by a single tenant. With them, you can point at the specific tenant, tell them their traffic is outside expected bounds, and either absorb it (if their tier supports it) or throttle it (if it doesn't).
Per-tenant compliance posture
PCI DSS compliance levels scale with transaction volume. Level 1 merchants (processing over 6 million card transactions per year) have more stringent requirements than Level 4 merchants (under 20,000). If your platform serves merchants across the spectrum, you have to support all compliance levels simultaneously.
This is mostly a documentation and process problem, but it has technical implications. Some Level 1 merchants require dedicated processing infrastructure for audit purposes. Others require specific vendor attestations. Some regulatory regimes (HIPAA for healthcare POS, regional financial regulations for specific markets) impose additional requirements on top of PCI.
The architecture has to accommodate this without bifurcating. The right approach is to build to the strictest tier and serve everyone from that infrastructure, but allow configuration-driven variation in things like data retention, log granularity, access controls, and audit logging. A merchant in a stricter regime gets more logging and longer retention; a merchant in a looser regime gets the standard settings. The code is the same.
Settlement and reconciliation per tenant
Settlement schedules vary by merchant. Some merchants settle daily. Some weekly. Some on demand. Some have settlement held pending investigation for risk reasons. Your platform has to track settlement state per merchant, run settlement processing on per-merchant schedules, and produce per-merchant settlement reports.
Reconciliation is harder. When you receive a settlement file from your processor, it covers many merchants. The file has to be split by merchant, matched against per-merchant transaction records, and discrepancies raised per merchant. If the processor's file groups transactions by their internal merchant ID rather than yours, you have to maintain a mapping, and the mapping has to be kept in sync or reconciliation breaks.
The statement is what the merchant actually sees. It tells them how much they're being paid, what fees were taken, what refunds were processed, what disputes are open. For the merchant, the statement is the product. Everything else is invisible plumbing. Making statements accurate, timely, and understandable is often what separates POS platforms that merchants trust from ones they don't.
Tenant-specific feature flags and configurations
Beyond payments, every merchant has different feature preferences. Some use loyalty programs, some don't. Some integrate with third-party accounting software. Some have custom receipt formats. Some require specific tax calculations.
The architecture has to support this without the code turning into a mess of per-merchant conditionals. The pattern that works:
Feature flags. Every feature is gated behind a flag. Merchants have feature configurations that enable or disable specific features.
Hookable extension points. Specific points in the transaction flow (pre-authorization, post-capture, at-settlement, on-refund) allow per-merchant hooks to run custom logic. The hooks are configuration, not code — they're pre-built pieces that merchants can enable and configure.
Configuration as data. Receipt templates, tax rules, loyalty configurations, integration credentials — all of this is data, rendered by general-purpose code, never hardcoded.
When a merchant asks for a feature that doesn't fit into this pattern, the answer is usually that the feature is wrong. "We just need this one special case for this one merchant" is how platforms become unmaintainable. The fix is either to generalize the feature (so any merchant can configure it) or to decline (because this isn't the merchant's platform, it's your platform).
The blast radius question
In multi-tenant systems, the most important question to ask about any bug is: what's the blast radius? A bug that affects one merchant's transactions is a merchant incident. A bug that affects all merchants' transactions is a platform incident. The architecture determines which kind of bug each class of code can produce.
The principle I follow is: merchant-specific configuration can cause merchant-scoped bugs, but platform-wide code should not. If a merchant's fee rule is misconfigured, the blast radius is that merchant. If the fee calculation engine has a bug, the blast radius is every merchant.
This means the boundaries between merchant-specific and platform-wide code have to be rigorous. Merchant-specific code — usually configuration interpretation, hook execution, custom rule evaluation — runs in an isolated context, with its merchant's data and its merchant's scope. Platform-wide code — the transaction engine, the ledger, the reconciliation pipeline — runs with full access but is held to a higher testing and review standard because any bug affects everyone.
The deeper architecture lesson
Multi-tenancy in POS SaaS is less about isolating tenants from each other and more about supporting tenant variation without losing architectural coherence. The challenge is that every merchant is a little bit different, and the naive response to this — add a flag, add a config option, add an exception — compounds over time into a system that nobody can reason about.
The discipline is to figure out which variation is actually variation (fee structures, processor configurations, compliance levels) and which is resistance to the platform's actual shape (requests for fundamentally different behavior). The first kind should be modeled as data. The second kind should be declined, because serving it means accepting a perpetual tax on every future change.
A well-designed multi-tenant POS platform has a thousand configurations and one codebase. A poorly-designed one has one configuration and a thousand exceptions. The difference is visible in how long it takes to add a new merchant — seconds for the well-designed, weeks for the poorly-designed — and also in whether engineers can be trusted to make changes without breaking someone.
Your multi-tenant architecture is the most important piece of engineering you'll do that customers never see. If you get it right, the platform scales. If you get it wrong, every merchant you add slows the platform down a little more, and the point at which it stops being profitable to add new merchants arrives sooner than you'd like.
This is part of a series on payment systems architecture. See also the real cost of payment integration nobody talks about and PCI compliance shapes your entire architecture.