All work

Case study · Multi-tenant SaaS

Food Link — a database for every merchant

A multi-tenant ordering platform for restaurants and stores, where creating a new merchant cannot be done in a single transaction.

The problem

Food Link sells online ordering to restaurants and stores as a subscription. Each merchant gets a storefront, staff accounts, a catalogue and customers with wallets. The requirement that shaped everything else: one merchant’s data must be kept apart from every other merchant’s.

My role

I was the only backend developer, from the empty repository onwards. This case study covers the platform core I built: tenancy and provisioning, subscriptions, the admin and merchant dashboards’ APIs, store settings, catalogue, customers and wallets, support tickets, the audit log and exports. The customer storefront and ordering were the next phase.

Architecture

A modular monolith with 18 modules. One central database holds the platform itself (merchants, packages, subscriptions). Every merchant has a database of their own.

  1. Route
  2. FormRequest
  3. DTO
  4. Service
  5. Repository
  6. Resource
Every endpoint follows the same path.

Modules do not import each other. They talk through contracts that live in a Core module, and each contract has a null implementation, so a module can be switched off without breaking the rest. I wrote the main decisions down as 24 architecture decision records.

Key decisions

A database per merchant, not a tenant_id column. I chose this for two reasons:

  • Isolation. A forgotten where clause cannot leak another merchant’s customers, because those rows are not in the database the request is connected to.
  • A clean way out. A merchant who outgrows the platform can leave with their own domain, their own data and a licensed copy of the code. With a database each, that is one database to hand over, with no rows to untangle from shared tables.

One place decides what a merchant may do. A subscription puts a merchant in one of four states: blocked, in setup, full access or read-only. A single service works that out and middleware enforces it. If the state cannot be resolved, writes are refused.

Rules enforced by tests, not by convention. 107 architecture tests fail the build when a module reaches into another module, or when a merchant’s model is used on the central connection.

Money as exact decimals. Prices are calculated to four decimal places and rounded at one point only. The customer wallet is an append-only ledger: every movement locks the wallet row and records the balance before and after.

The hard part: creating a merchant

Signing up a merchant means writing to the central database and creating a new database. In MySQL, CREATE DATABASE commits implicitly, so it cannot sit inside a transaction and be rolled back with the rest. If a later step fails, a half-created merchant is left behind.

I built provisioning as a sequence of steps where each step knows how to undo itself.

  1. Create the merchant and domain records

    Central database, inside one transaction.

  2. Create the merchant database

    Outside any transaction, because the statement commits by itself.

    If it fails: remove the central records.

  3. Grant the database user and set the character set

    If it fails: drop the database, then remove the records.

  4. Run migrations and create the owner account

    Inside the new database.

    If it fails: drop the database, then remove the records.

  5. Mark the merchant as ready

Each step after the first has a compensating action that runs if a later step fails.

There was a hosting constraint too. The company’s servers run cPanel, where an application cannot simply create databases with a privileged MySQL user. I wrote a database manager that goes through cPanel’s own API to create the database, grant the user and set the character set. cPanel can report a failure while still exiting successfully, so the manager reads the response body and does not trust the exit code.

Outcome

  • The platform core was delivered in about seven weeks: 18 modules and roughly 250 API routes.
  • About 2,900 test methods cover it, including tenant isolation, read-only mode, provisioning and the cPanel failure paths.
  • The architecture is documented in 24 decision records, so the reasoning travels with the code.

What I would do next

  • Run the suite against MySQL in CI. Tests currently use SQLite in memory, which does not exercise row locking the way production does.
  • Add static analysis and a pipeline that runs the tests on every merge request.
  • Add a test for the full rollback of provisioning, alongside the existing tests for each step.

CV

Download PDF
CV page 1CV page 2