SaaS engineering

What building a multi-tenant SaaS actually takes

The ArendaOS build, decision by decision — including the three that would have been expensive to reverse, and the one number we refuse to publish.

Short answer. The screens are not the work. In a B2B product that handles money, custody and evidence, the work is deciding which rules the database enforces and which ones the application merely checks — because everything in the second category eventually gets written by a code path nobody remembered. Three decisions carry most of the weight: tenancy belongs in the schema before the first feature, conflicts belong in constraints rather than validators, and compliance belongs in the architecture rather than in a later sprint. All three are cheap on day one and close to a rewrite in year two. This article uses ArendaOS, our own live product, as the worked example, so every claim in it points at something you can open.

Why this article names the product it describes

Most engineering write-ups from agencies describe a system the reader can never look at. This one describes ArendaOS, a subscription product for car-rental and fleet-rental operators that Yarvixo designed, built and runs. It is live, in Russian, at arendaos.com. That matters less as a portfolio item than as a constraint on this article: when the product is public, the claims have to survive somebody checking them.

It also changes who is holding the consequences. An agency hands a system over at the end of an engagement and stops paying for its architecture. We are still paying for these decisions — in migrations, in support, in the shape of every feature added since — which is the only reason we have an opinion worth reading about which ones were right.

Decision 1: tenancy is a schema choice, not a middleware choice

The tempting version of multi-tenancy is a filter: attach the current organisation to the request, add a where clause in a shared helper, done in an afternoon. It works until something writes without going through the helper — a background job, a CSV importer, a reporting query, an admin screen, a one-off migration script written under time pressure.

The failure mode is what makes this different from an ordinary bug. A missed filter is not a broken page; it is one customer seeing another customer's contracts, deposits and driving licences. That is a breach, and in a product holding identity documents it is a breach with a regulator attached.

ArendaOS was multi-tenant from the first migration, before any product feature existed. Not because we expected many customers on day one, but because the alternative is auditing every query in the system later and having to be right about having found all of them. There is no version of that audit that ends in confidence.

On top of the boundary sit seven roles with per-section permissions, configured by the fleet owner rather than by support. That last detail is a product decision more than a technical one: an owner who has to open a ticket to stop a new manager seeing the finance section will simply give everyone full access instead.

Decision 2: enforce it, or you have not prevented it

The rule that decides whether a rental system is trustworthy is short: one vehicle cannot be rented to two people at overlapping times. The usual implementation is a validator — look for a conflicting rental, and refuse if you find one.

That has two problems, and only one of them is obvious. The obvious one is coverage: the check has to be repeated in every path that writes a rental, and the list of those paths grows for as long as the product does. The subtler one is that check-then-write is racy by construction. Two requests arriving together both look, both see no conflict, and both write.

So in ArendaOS the rule is not a check at all. Overlapping rentals for a vehicle are refused by a PostgreSQL exclusion constraint: the conflicting row cannot be written, by any code path, including ones that do not exist yet. The product's landing page states zero double bookings as an absolute, and the reason it is allowed to is that the statement is about an enforced invariant rather than an intention.

The shape of it is small enough to be worth showing, because most teams who would benefit from this have never seen one:

-- GiST indexing over ranges; btree_gist is what lets a plain
-- equality column (vehicle_id) sit in the same index as a range.
CREATE EXTENSION IF NOT EXISTS btree_gist;

ALTER TABLE rentals ADD CONSTRAINT rentals_no_overlap
  EXCLUDE USING gist (
    vehicle_id WITH =,
    tstzrange(starts_at, ends_at, '[)') WITH &&
  )
  WHERE (status <> 'cancelled');
Three details carry the weight, and each one has a failure mode no test suite stumbles into: the half-open '[)' range, without which no back-to-back handover in the business is bookable; the partial WHERE, without which a cancellation cannot free the slot unless the history goes with it; and btree_gist, without which the statement simply fails. All three — plus the error code to map, the turnaround buffer that cannot be computed inside the constraint, and the alternatives worth considering — are in preventing double bookings in PostgreSQL.

What this buys is not elegance. It is that the guarantee survives the parts of the system nobody is thinking about — the Excel importer, the super-admin console, a data-repair script run at 2am, and the endpoint a developer adds in eighteen months without reading this article. A validator protects the paths you remembered. A constraint protects the ones you did not.

RuleWhere it livesWhat that buys
No overlapping rentals per vehicle Database constraint Cannot be bypassed by any writer, and safe under concurrent requests
Tenant isolation Schema, from migration one No later audit of every query, and no class of cross-customer leak
Personal data at rest Encrypted (AES-256-GCM) A database copy is not a disclosure of passports and licences
Who changed a payment, deposit or status Audit journal A damage or debt dispute months later has a record behind it
Business rules that are genuinely judgement calls Application code Room to change without a migration — which is why they are not in the schema

The last row is the part that keeps this from being a slogan. Not everything belongs in a constraint. A rule that will legitimately have exceptions — a discount policy, a grace period, a workflow order — is worse in the schema, because every exception then costs a migration. The test we use is whether a violation of the rule would be a data problem or a business problem. Two people holding one car at the same time is a data problem.

Decision 3: build the money model before the documents

Rental operations look like a scheduling product and behave like an accounting one. Money arrives in at least five distinct shapes — prepayments, recurring rent, deposits held and later released, debts, and traffic fines rebilled to the driver — and each has a different owner and a different moment at which it becomes true.

The mistake available here is to ship the documents first, because contracts and acts are visible and demo well. Then the ledger is fitted to the paperwork, and the two disagree the first time a deposit is partially withheld. ArendaOS shipped payments, deposits, debts and fines alongside the PDF contracts and acts that reference them, in the same layer, so the paperwork and the ledger were never separately true.

Every amount in that layer is stored as an integer in minor units — whole kopecks, never a float — across all three currencies the product carries. This costs nothing on the first migration and is close to unrecoverable afterwards: by the time a half-kopeck drift shows up in a deposit reconciliation, the wrong figure is already in the ledger, in a signed PDF, and in the customer's memory of what they handed over. Rounding is a display concern and belongs at the edge, not in the column type.

Two further supporting decisions came out of that and are worth stating because they are easy to get wrong quietly. PDF rendering and Excel export run as background jobs on Redis and BullMQ rather than inside the request — a fifty-vehicle export must not be able to hold a web request open or time out behind a proxy. And due dates are stored as end-of-day instants in UTC, because a payment due "today" that becomes overdue at midnight is overdue before breakfast for anyone east of the server.

The part that is not architecture: evidence

The module that decides who pays for a scratch is handover and return. It is a checklist covering four areas — body, interior, glass, wheels — a damage diagram, photographs, a signature captured on the customer's own phone, and a before/after comparison. Its job is to produce a record that survives a dispute months later, not a form that gets filled in. Behind it sits an action journal recording who changed a payment, a deposit, a status, a document or a customer record, and when.

This is the module where a product for this market is won or lost, and it is also the one most often built as an afterthought, because it looks like data entry. It is not: it is the operator's only defence, and every field that is optional in it is a field that will be empty on the day it matters.

What this article deliberately does not tell you

ArendaOS is early. We publish no customer count, no revenue and no uptime figure for it, here or on its own site, because none of those are things we can evidence yet and a number invented to fill a gap is worth less than the gap. We also publish no price. The product has three tiers separated by fleet size — Start, Pro and Business — and no sum appears on either site; any per-car or per-month figure attributed to it did not come from us.

What the product does not do is published too, on its own comparison page: there is no GPS telematics, no remote immobilisation, no full online payment processing and no public booking site in the current version. A vendor page that lists only capabilities forces a prospect to discover the limits during a trial, which is a worse conversation than the one at the start.

How this applies to a build that is not ours

  1. Write down which rules are invariants. Before any schema exists, list the statements that must never be false. Those are constraint candidates; everything else is application logic, and mixing the two is what produces both racy validators and un-migratable business rules.
  2. Put the tenant boundary in the first migration. It is the cheapest thing on this list on day one and among the most expensive in year two. There is no middle option that is not an audit.
  3. Ship the money model with the paperwork that references it. Not before, not after — together, so they cannot drift.
  4. Treat compliance as design. Encryption at rest, an audit journal and a full data export are architecture. Added later they touch every table; designed in, they are a week. The same work covers GDPR for an EU client that covers Law No. 99-З and Federal Law No. 152-ФЗ for this one.
  5. Decide what you will refuse to publish. The limits and the absent numbers are the part of a product page a buyer actually trusts, and writing them down early stops the marketing copy from making promises the engineering has to keep.

What a build like this costs

A SaaS MVP with a real multi-tenant surface runs $40,000 to $90,000 and typically ships in 8 to 12 weeks, extending to 14–20 when AI features or a complex data model are in scope. The bands, and what is deliberately not banded, are on our pricing page; the full delivery story behind this article is the ArendaOS case study, and the service that covers this kind of work is SaaS development.

Common questions

The five that come up in the first call, answered the way we would answer them there.

Technically yes, and it is one of the most expensive retrofits in this category. Adding a tenant boundary after launch means auditing every query, every background job, every export and every admin screen, and being certain you found all of them — because the failure mode is one customer seeing another customer's data, which is a breach rather than a bug. Doing it in the first migration costs days. Doing it in year two costs a quarter and a trust problem.
In the database, whenever the domain allows it to be expressed there. Application validation has to be repeated in every path that writes — the API, the importer, the admin console, the migration script, the endpoint nobody has written yet — and a check-then-write pair is racy under concurrency regardless. In ArendaOS overlapping rentals for one vehicle are refused by a PostgreSQL exclusion constraint, which is why the product can state zero double bookings as an absolute rather than as an intention.
Both work; a database per tenant is the option that usually does not. A tenant column plus a discipline enforced in one data-access layer is the cheapest to operate and the easiest to migrate, which is what ArendaOS uses. Row-level security moves the boundary into PostgreSQL itself and is stronger, at the cost of every connection needing the right session context — get that wrong and queries return nothing, loudly, which is at least a safe failure. A database per tenant sounds safest and turns every schema migration into an N-times operation and every cross-tenant report into a distributed query; it is justified by a regulator demanding physical separation, and rarely otherwise.
For a first version with a real feature surface, months rather than weeks, and the honest answer depends on how much of the domain is money and evidence rather than screens. A product that records information is fast. A product that has to be right about who owes what, what deposit is held and who caused a given piece of damage carries a different standard of correctness, and that standard is what the timeline is actually paying for. Yarvixo quotes a fixed price against a written scope within 48 hours of a discovery call.
The list of statements that must never be false. Write it before the schema exists. Everything on that list is a candidate for a database constraint; everything else is application logic that you are free to change later. Teams that skip this step end up with the two categories swapped — racy validators guarding the invariants, and business rules frozen into the schema where every exception costs a migration.

Building something with the same shape?

Bring the operational problem rather than a feature list. We will name the rules that have to be enforced instead of validated, and put a scope and a fixed price in writing within 48 hours.

Message received

We’ll review your enquiry and respond within one business day.

Related reading