Multi-tenant CRM: 19 modules, more than 4,000 tests
I was technical lead on a multi-tenant CRM for the curtain and home-textile trade. Below: the architecture, how tenant isolation is actually enforced, and what a handover that another team can pick up looks like.
What was the project?
A multi-tenant CRM in which dealers run their whole process — from quote to installation — in one system. A single codebase and a single database serve many dealers, and each dealer sees only its own data. The product was built and shipped at Qolay Bilişim; my role was technical lead.
- Role
- Software Developer Technical Lead
- Period
- 2025 – 2026
- Module groups
- 19
- Automated tests
- 4,000+
- Architecture
- Multi-tenant · single database, isolation by tenant id
- Status
- In production · shipped
What stack was used?
Angular on the front end with Ionic/Capacitor as the mobile target, Node.js/Express with Sequelize on the back end, MySQL 8 for data. Background jobs run on BullMQ over Redis, files in MinIO, push notifications via Firebase Cloud Messaging and SMS through a local provider. The whole stack runs on one virtual server under Docker Compose.
| Layer | Choice | Role |
|---|---|---|
| Web front end | Angular · TypeScript | Primary interface for dealer and head-office users |
| Mobile | Ionic · Capacitor | Second build target of the same codebase, for field use |
| Back end | Node.js · Express · Sequelize | Business rules, authorisation, data access |
| Database | MySQL 8 | All tenant data, partitioned by tenant id |
| Queue | Redis · BullMQ | Report generation, notifications, batch jobs |
| Object storage | MinIO | Documents, images and generated quotes |
| Messaging | Firebase Cloud Messaging · SMS gateway | Push and SMS |
| Runtime | Ubuntu · Docker Compose · nginx · Certbot | Single VPS with a three-tier network split |
| Delivery | GitHub Actions · container registry | Continuous delivery with pinned image tags |
How is tenant isolation enforced?
Isolation rests on one rule: the tenant identifier is not an optional filter, it is a mandatory part of the data layer. That rule is applied in three separate places — synchronous requests, queued background jobs and the object-storage path scheme — and in all three it is protected by tests rather than by convention.
- Request level. Tenant context is fixed after authentication; the data access layer will not build a query without it.
- Background jobs. Every queued job carries its own tenant id. There is no “current user” in a worker, and assuming there is, is the most common failure mode.
- Tenant activeness. When a tenant’s subscription lapses, access is cut in middleware. To avoid a database round trip on every request, that check is cached in Redis.
- Object storage. File paths are partitioned by tenant; signed URLs are bounded in lifetime and scope.
- Tests. Leak tests are the most critical part of the suite. “Can tenant A’s record appear for tenant B?” is asked for every module separately.
How do schema changes work in a multi-tenant system?
Schema changes are additive, never destructive. The pattern is expand and contract: add the new field, have the code write both old and new for a period, migrate and verify the data, then drop the old field. Risking every tenant in a single migration step is not acceptable.
- Expand. The new column or table is added; the old one stays. This step is reversible.
- Dual write. The application writes both fields; reads still use the old one.
- Migrate and verify. Historical data is moved and the two fields are checked for consistency.
- Switch reads. Reads move to the new field; the old one is still written.
- Contract. The old field is dropped. Up to this point every step was individually reversible.
The cost of this discipline is that a change no longer lands in one go. What you get in return is a place to roll back to when a migration step fails. In a multi-tenant system that trade is always made in this direction.
What does the handover look like?
A handover is not a running system; it is a setup another team can take over. On this project that package included a Docker Compose definition with pinned image tags, a three-tier network split, encrypted backups, a health endpoint, and a written project rules file holding version numbers and domain invariants.
- Pinned image tags. A production environment running on a floating “latest” tag is by definition not reproducible.
- Three-tier network. Public, application and data tiers on separate networks; the database is never exposed.
- Health endpoint. Returns the state of each dependency individually — it answers “what can it do”, not “is it up”.
- Encrypted backups. On the condition that restoring them is tested, not just that they exist.
- Project rules file. Version numbers and domain invariants (tenant isolation, tax arithmetic) written down, so the next developer does not have to reverse-engineer them.
Three things this project taught me
- Isolation is an invariant, not a feature. Features get forgotten; invariants get bound to tests. The tenant-leak suite was the least-changed and most protective part of the product.
- The feedback list grows faster than the architecture. More than 55 change requests from the field were grouped into 19 module groups and ranked by risk. Working without that ranking means leaving the highest-risk item for last.
- Mobile parity must be a rule, not an intention. Unless mirroring every web change to mobile is written down as a rule, the two targets drift apart within six months.
Last updated: