Anchor
Multi-tenant church platform extracted from a single-tenant production app, with the tenant boundary enforced in Postgres row-level security rather than in the client.
TL;DR
- Multi-tenant platform extracted from a single-tenant app that had to stay in production for its congregation throughout.
- The tenant boundary lives in Postgres row-level security, not in Angular. A forgotten filter produces a missing row rather than a cross-organization leak.
- 28 shared repository interfaces bound through DI tokens made the extraction a configuration change instead of a rewrite.
- 22 feature areas live and tenant-scoped, from directory and ministries through prayer, serving, attendance, and youth.
- Feature areas
- 22
- Repository interfaces
- 28
- Scope references
- ~400
Overview
Anchor is a multi-tenant church platform running at anchor.parkerbible.church. It covers 22 feature areas including people and directory, ministries, prayer, serving, attendance, calendar, signups, youth, messaging, and qualifications.
It exists because of a constraint that shows up in a lot of product work and rarely gets written about honestly: the thing you want to build is the general version of a thing that is already in production for one customer, and the one customer cannot go down while you build it.
The problem
The predecessor is a member directory built for a single church, in production and in daily use. Every table assumed one organization. Every query assumed one organization. That assumption was correct when it was written and it was load-bearing by the time it needed to change.
Adding a second organization to that codebase means one of two things. Either you thread an organization identifier through every query and hope you did not miss one, or you enforce the boundary somewhere the application cannot forget about it.
Approach
The tenant boundary lives in the database
The organization scope is enforced in Postgres row-level security, not in Angular. A component that forgets to filter by organization does not leak another church’s directory; it gets an empty result, because the policy on the table never had those rows in scope for that session.
This is the decision the whole architecture rests on. Client-side tenant filtering is one forgotten .eq() away from a data breach that nobody notices for a year, and the failure is silent in the direction that matters. Putting the boundary in the database inverts that: the failure mode becomes a missing row rather than an extra one, and a missing row gets reported by a user within a day.
The policy work is where the real complexity ended up. Recursive policy evaluation on the roles table had to be untangled, anonymous access to trigger functions had to be revoked explicitly, operator grants had to be scoped to individual ministries rather than the whole organization, and platform-administrator visibility had to be modeled as its own path rather than a bypass.
Tenant scoping runs through roughly 400 references across the application. None of them are the security boundary. All of them are there so the interface asks for the right thing; the database is what guarantees it.
Repository interfaces made the extraction possible
The shared library layer defines 28 repository interfaces plus dependency-injection tokens, and the implementations are bound per application:
// libs/core: implementation
@Injectable()
export class SupabaseRoleRepository implements RoleRepository { /* ... */ }
// app.config.ts
{ provide: ROLE_REPOSITORY, useClass: SupabaseRoleRepository }
// consumer
private roleRepo = inject(ROLE_REPOSITORY);
Because consumers depend on the token rather than on Supabase, Anchor reuses those repositories as-is instead of forking them. The extraction became a configuration problem rather than a rewrite, and the original single-tenant app kept running on the same contracts throughout.
The auth flow works the same way. Five components live in a shared library and are configured per application through provideAuthFlow() and two tokens, so both apps get the same sign-in, callback, password-set, reset, and confirm behavior without either owning it.
Boundaries the tooling enforces
Nx enforces the module boundaries, so the dependency rules are a build failure rather than a code-review preference. Routes have to lazy-load. Libraries cannot reach across into each other in ways the graph forbids.
I have written architecture guidelines that nobody followed. The difference between a guideline and a constraint is whether the machine checks it, and this is the same lesson that showed up in the Acorn design system as stylelint configuration and pre-commit hooks. Different layer, same principle.
Testing the parts that are expensive to get wrong
The suite covers three kinds of failure separately: Playwright end-to-end tests for flows like self-service signup, accessibility tests, and visual regression snapshots for surfaces where a silent rendering change matters, including session timeout and print output.
Print chrome having its own visual test is not fastidiousness. A church directory gets printed, and print is exactly the surface where a CSS change ships unnoticed because nobody on the team prints anything.
Outcomes
- 22 feature areas live and tenant-scoped, from directory and ministries through prayer, serving, attendance, signups, and youth.
- Tenant isolation enforced in the database, so a client-side mistake produces a missing row rather than a cross-organization leak.
- 28 shared repository interfaces reused rather than forked, which is what let the successor platform be built without freezing the predecessor.
- The original app stayed in production for its congregation for the entire extraction.
Reflection
The interesting decision here was refusing to put the tenant boundary where it was easiest to write.
Filtering by organization in the Angular services would have been faster to build and would have looked identical in every test I would have thought to write. It would also have meant that the correctness of the isolation depended on every future query, written by anyone, forever. Row-level security cost more up front, and the cost was concentrated in a genuinely hard week of policy debugging. What it bought is that the guarantee no longer depends on remembering.
That trade shows up constantly in system design and it usually looks like the wrong call at the moment you make it, because the expensive part is immediate and the payoff is an absence of incidents you never get to point at.
What’s next
- Continue moving prototype features from the predecessor app into tenant-scoped implementations here.
- Extend the visual regression coverage to the remaining print surfaces.