Every school we onboarded wanted to track something the last one did not. House colours. Bus routes. A second guardian with different pickup rights. Individually trivial; collectively, the thing that turns one product into a fork per client.
Dynamic entities instead of migrations
The usual answers are both bad. Add every field anyone asks for and the schema becomes a swamp of nullable columns. Fork per client and you are maintaining a codebase per tenant with one team.
Instead we let a tenant define the entities and fields it actually uses, and stored that definition as data. Bringing a new school online became configuration rather than a migration and a deploy — which is where most of the per-tenant setup time had been going.
The discipline this requires is validation. Schema-as-data without validation is just an untyped blob, so every tenant-defined field carries its type and constraints, and writes are checked against them at the boundary.
Roles are not the permission model
The harder half was access control. Role-based access sounds sufficient until you write the actual rule. A teacher can see grades — for their own students. A parent can see records — for their own children, and only the ones the other guardian has not restricted.
None of that is expressible as 'role = teacher'. The permission depends on the relationship between the person asking and the record, which is exactly what a school hierarchy is made of. So we used Relationship-Based Access Control and derived permission from the graph.
- Enforce at the data layer, never in the UI. A hidden button is not a permission boundary.
- Make the relationship explicit and queryable, so an access decision can be explained after the fact.
- Default to deny, and make a missing relationship a denial rather than an error.
- Test the negative cases hardest — the bug you cannot afford is a parent seeing another child's record.
That last point is why this is worth the effort. The cost of getting multi-tenancy wrong here is not a bad review. It is a child's records in front of the wrong adult.