Every SaaS they started rebuilt tenants, plans and the audit trail, so we built those once
A tenancy and billing scaffold (tenants, pricing plans, per-user preferences and an activity log) built once as the starting point for a vendor who had written the same four things into four different products.
Who, what, and how long
Professional Services
8 weeks
Fixed price
Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.
What was there, and what replaced it
Four products, four implementations of tenancy, and four different bugs. In two of them, a plan limit was a number on a pricing page that nothing checked. In one, the activity log was added after an incident and couldn't answer questions about anything before it. None of them agreed on what a tenant even owned.
Each rebuild had gone wrong differently, and all under the same pressure. In two products, a plan limit was a number on a pricing page that nothing checked, enforceable only by hope. In one, tenant scoping was a where clause each query added, which held until someone wrote a query on a Friday. None of the four agreed on what a tenant even owned.
One scaffold that makes the easy-to-get-subtly-wrong parts hard to get wrong. Tenant scoping is applied globally, beneath every query. A plan limit is checked at the point of use. The activity log is written by the same layer that performs the action, so it can't be forgotten.
What we inherited
The vendor starts several products a year, and each one had rebuilt the same foundation. We built it once: tenant isolation, pricing plans whose limits are actually enforced, per-user preferences, and an activity log that records who did what.
A vendor starting several products a year, each of which had rebuilt the same foundation: tenancy, plans, preferences, audit. Four products meant four implementations and four different bugs. The engagement was eight weeks to build it once, properly, and it was prompted by an incident in one product that the activity log couldn't explain because the log had been added after the fact.
Full-Stack Engineering
What we kept, and what we cut
Applied tenant scoping globally, so an unfiltered query can't happen by accident
Global scoping won over per-query filters because the failure mode is omission, and omission is what happens when someone is in a hurry.
Made plan limits enforced at the point of use
A plan limit is checked server-side where the resource is created and returns the specific limit hit with the upgrade path, so an API call can't bypass the interface.
Wrote the activity log from the layer that performs the action
Writing the log from the acting layer was chosen after one of the vendor's products had to answer an incident with a log added after the fact.
Kept user preferences separate from tenant settings, because they answer different questions
User preferences and tenant configuration are stored separately, because an administrator changing a company setting shouldn't overwrite an individual's choice.
Built the admin surface for the vendor's own staff, which is who actually operates a tenancy
The operator console (tenants, plans, subscriptions, logs) shipped on day one, so nobody had to build it badly during a first incident.
Global tenant scoping
Isolation is applied below the queries, so a new feature can't make an unfiltered read by accident.
Tenant scoping is a global scope applied beneath every query, so no feature has to remember a where clause. That inverts the failure mode: an unscoped read has to be deliberately opted into and named, where before it was what happened when someone wrote a new query on a Friday. It's the single decision that determines whether a multi-tenant product eventually leaks.
- Global scope applied below every query
- Unscoped reads must be opted into and named
- A new query can't leak by omission
The isolation guard reporting on itself: one tenancy implementation and no per-query tenant filters, the models covered by the global scope, the unscoped queries it refused this week, the named opt-ins, and the checks that run on every deploy.
Four plans side by side with Growth's limits open for editing, the action each ceiling is enforced in, and a count of the tenants the change would reach at their next renewal.
Limits enforced, not displayed
A plan's limit is checked where the resource is created, and the pricing page just reports it.
A plan limit is enforced where the resource is created. The check runs server-side at the point of use and returns the specific limit that was hit with the upgrade path attached, so the user gets an answer instead of a bare refusal, and an API call that skips the interface can't bypass it.
- Enforced server-side at creation
- The specific limit named, with the upgrade path attached
- An API call can't bypass what the UI displayed
Audit by construction
The activity log is written by the layer performing the action, so a new feature can't forget it.
The activity log is written by the same layer that performs the action, so a new feature is logged simply by existing there. Its author doesn't have to remember. That's the only kind of audit trail that stays complete past the first six months; the ones written from call sites are always missing exactly the actions someone later needs.
- Written by the layer performing the action
- A new feature is audited by existing
- Stays complete past the first six months
The activity log: actor, tenant and channel for every action, refusals included, with one plan change expanded to show what the upgrade altered and the action that wrote the entry.
Preferences separated from settings
Personal preferences: notification channels, locale and display, second factor and revocable sessions, beside the tenant's own settings shown read-only and stored in a separate table.
A user's choice and a tenant's configuration answer different questions and are stored separately.
A user's preference (theme, density, default view) and a tenant's configuration (branding, policy, defaults) answer different questions and are stored separately, so an administrator changing a company setting doesn't overwrite what an individual chose. Collapsing the two is a common shortcut, and it produces a bug that's expensive to unpick later.
- User preferences and tenant configuration stored separately
- An admin change can't overwrite an individual's choice
- Avoids a merge that's expensive to unpick later
An operator console from the start
The vendor's own staff have the tenant, plan and log surfaces they need on day one, well before any incident.
The vendor's own staff get tenant, plan, subscription and activity-log surfaces from day one, before an incident makes them necessary. An operator console built during an incident is always built badly and in a hurry. Building it up front costs a fraction of that, and the first support question already has an answer.
- Tenant, plan, subscription and log consoles on day one
- Built calmly, well before a first incident
- The first support question already has a screen
Provisioning a tenant: the subdomain checked as it's typed, the plan chosen up front, and every seeded default (roles, tenant settings, the owner's preferences and records) listed before anything is created.
Phase by phase
Phase 1: Tenancy
Scoped Globally
Built tenant isolation as a global scope that no query has to remember. The class of leak it prevents is the one that had already happened twice.
- Tenant Model
- Global Scope
- Tenant Switching
Phase 2: Plans
Limits That Bite
Built pricing plans whose limits are checked where the resource is created. A limit that only appears on a pricing page is marketing, and this one is a constraint.
- Pricing Plans
- Limit Enforcement
- Plan Assignment
Phase 3: The Activity Log
Written By The Action
Built logging into the layer that performs actions, so a new feature is audited by construction and nobody has to remember to call a logger.
- Activity Log
- Actor & Target
- Log Browsing
Phase 4: Preferences
User, Not Tenant
Kept per-user preferences distinct from tenant configuration, because conflating them is how one user's choice becomes everyone's setting.
- User Preferences
- Tenant Settings
- Defaults
Phase 5: The Operator Console
For The Vendor's Own Staff
Built the admin surface the vendor's team uses to create tenants, move plans and read the log: the part every one of the four products had left until last.
- Tenant Admin
- Plan Admin
- Log Viewer
The tenants list in the operator console: every tenant's plan, seats against the plan's ceiling, status and renewal, with the tenants near a ceiling flagged.
After the rebuild
1
Implementations of tenancy
None
Per-query tenant filters
All
Plan limits that are checked
0
Features that can skip the log
Implementations of tenancy is one. Per-query tenant filters is none: scoping is applied globally beneath the queries. Plan limits that are checked is all of them, enforced at creation. Features that can skip the log is zero, because the log is written by the layer that acts.
Client name withheld under NDA. Figures are approximate, drawn from the engagement’s own reporting.
What the rebuild taught us
Anything rebuilt for every product will eventually be built wrong under a deadline. Build it once.
Anything rebuilt per product is eventually built wrong under a deadline, and the four implementations had four different failures for exactly that reason.
A plan limit rendered on a pricing page and never checked is a support ticket with a date on it.
A limit that's displayed and never enforced is a support ticket with a date on it, and the date is whenever a customer first exceeds it.
Audit logging belongs in the layer that acts. Leave it to the caller and every new feature is a chance to skip it.
Logging from the caller makes every new feature a chance to forget. Logging from the acting layer makes completeness a property of the system, no discipline required.
The operator console isn't a nice-to-have. It's the surface the vendor lives in, and it's always deferred.
The operator console is where the vendor's own staff live, and it's always deferred, which is why it always gets built in a hurry during the first incident.
How we worked alongside the team
- 01
We started from the vendor's own incident write-ups, with no feature list. Each of the five parts of the scaffold traces to a specific thing that had already gone wrong in one of the four products, which is why the scope stayed small enough to actually finish.
Eight weeks, fixed, built as a scaffold instead of a library: the vendor starts products from it without depending on it, which suits a team that will inevitably need to diverge. The operator console was in scope from the start, because every previous product had built one during an incident and built it badly.
Four requests, one tenancy layer
A filter someone has to remember will be forgotten. A scope beneath the query can’t be.
The same board query with and without the global scope, a queue job that has no tenant, and a create over the API at the plan’s ceiling, each followed to the line the activity log gets. Switch tabs, or use the arrow keys once one is focused.
Scoping as a where clause each query adds holds until someone writes a query without one. Nothing is wrong with this code except what it left out, and so the board shows other tenants' projects.
- Code written
- SQL sent
- Rows returned
- Log
// the filter this query forgotProject::where('status', 'active') ->latest()->get();
What reaches MySQL
select * from projects where status = 'active' order by created_at desc
What the board shows
- Harbor Street Clinic fit-outtenant 1042
- Unit 4 lease surveytenant 0977
- Lakeview townhomes, phase 2tenant 1042
- Depot yard drainagetenant 1108
- Quarterly fleet inspectiontenant 0977
- Mill Road footbridgetenant 1108
4 of 6 rows belong to other tenants.
Code, SQL, ids and log lines are illustrative, and so is the step timing. The mechanisms are the ones the scaffold ships: one global scope, limits checked where the resource is created, and a log written by the layer that acts.
From any request to a row, through one tenancy layer
The parts that are easy to get subtly wrong sit beneath the features. A feature built on the scaffold is scoped, limited and logged just by existing there. Its author doesn't have to remember a thing.
- 01 · TriggerA web request, API call or jobEvery channel enters the same actions, so an API call can't bypass what the interface enforces.
- 02 · IsolationGlobal tenant scopeApplied once, beneath every query. An unscoped read has to be opted into and named.
- 03 · EngineThe action, with its plan limitThe limit is checked server-side where the resource is created, and a refusal names the limit hit with the upgrade path.
- 04 · StateMySQL: rows, settings, logThe activity log is written by the layer performing the action; user preferences and tenant settings are stored separately.
- 05 · DeliveryLivewire operator consoleTenant, plan, subscription and log surfaces for the vendor's own staff from day one, well before any incident.
What a hurried feature can no longer do
Tenant isolation, enforced limits & a complete audit trail
One tenant can't read another's rows by omission
Tenant scoping is a global scope beneath every query, so there's no where clause for anyone to forget. An unscoped read has to be opted into and named, so a query written in a hurry can't leak by leaving something out.
A plan limit is a constraint, checked every time
Every limit is checked server-side where the resource is created. The refusal names the limit that was hit with the upgrade path attached, and an API call that skips the interface meets the same check.
No action, admin or otherwise, goes unrecorded
The activity log is written by the layer that performs the action, so a new feature is logged by existing. Operators get the log viewer from day one, so the first question after an incident has an answer.
Rebuilding tenants, plans and the audit log for every new product? Scope your build in 3 minutes.
Scope your buildNearby engagements
AI & AutomationA private legal assistant grounded in verified precedents
A private knowledge assistant that searches internal case files and precedents, providing cited answers legal teams can verify in seconds.
Legal & Law Firms · 14 weeks
Product DesignAn onboarding flow that guides trial users to value
A redesigned SaaS trial onboarding experience with progressive checklists, sample data, and inline guidance that turns signups into active subscribers.
Professional Services · 10 weeks
Product DesignA design system that brought speed and consistency to 4 product teams
A token-based design system in Figma and React that eliminated component duplication across 4 product squads and cut the time from design handoff to merged frontend.
Professional Services · 14 weeks
Let's talk
Running a large platform, shaping a first MVP, or getting a product ready for a funding round? Tell us where you are. We'll shape the process around it, and stay with you after launch.














