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');
'[)' 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.
| Rule | Where it lives | What 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
- 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.
- 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.
- Ship the money model with the paperwork that references it. Not before, not after — together, so they cannot drift.
- 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.
- 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.