Multi-tenant ERP
An ERP from scratch (finance, accounting, accounts payable and HR) built to hold more than one client in the same database without leaking a single row between them.
The problem
A small company that buys an off-the-shelf ERP pays for modules it never uses and cannot change anything. A company that commissions a custom one pays a lot and stays locked to whoever built it. I wanted to understand what sits in between: a system where each client is a tenant in the same database, with the same modules, and the difference between them is configuration, not code.
The interesting part is not writing the screens. It is that the moment two clients share a database, every programming mistake becomes one client’s data leaking into another’s. That is the real problem of this project, and it is what the decisions below are about.
Technical decisions
A global tenant filter that fails closed, not open
Against what: repeating WHERE tenant_id = @x in every query, which was the earlier design, and a global filter that, with no tenant resolved, released every row of every tenant.
Today the DbContext applies an automatic filter to every entity implementing ITenantScoped. With no tenant in context, the query returns nothing: instead of returning everything.
What it cost: anyone who needs a deliberate cross-tenant read now has to write IgnoreQueryFilters() explicitly. More friction on the rare path, in exchange for making the common mistake impossible. The design-time factory runs with no context and therefore filters everything out; that is harmless because design-time only runs migrations, but it is exactly the kind of corner that has to be documented in the code itself so nobody “fixes” it wrongly later.
One attribute per controller, which both declares and enforces the permission
Against what: every action rewriting its own access check.
Discipline failed in practice: there was a commit that leaked precisely because it trusted the existing check, not because someone disabled it. So authorization became a single attribute at the top of the controller, [TenantModule("HR.Employees")], declaring which resource that controller exposes and gating it on the same line.
What it cost: the attribute resolves permissions against the tenant in the token, while module controllers read data by the tenant in the route. If those two disagree, the answer has to be a 403, because authorizing against one tenant and reading from another is worse than not authorizing at all. That means route and token are now coupled, and a client that builds the “wrong” URL gets an error instead of data.
An architecture test that breaks the build when someone forgets
Against what: trusting code review to remember the attribute.
A test walks the controllers and fails if any of them did not declare the gate. A new endpoint written carelessly does not get through.
What it cost: and this is the part almost nobody writes down: the test catches forgetting, not getting it wrong. A controller that declares [TenantModule] with the wrong resource code passes the test and stays wrong. The net catches the person who forgot, not the person who wrote nonsense. And knowing exactly where the net has a hole is the difference between having protection and assuming you do.
What was left out
Caching. There is a Redis module written, working and wired to nothing: the API had an ICacheService that nobody ever injected. I preferred to leave it recorded as unused rather than pretend it is part of the system. It goes in when caching earns its own cost.
The system has no client with real data yet. That is why the state here is working, not live. It only changes the day someone loads real data into it.
[Stack]
- .NET
- PostgreSQL
- Angular
- Terraform
- Azure
- Docker