What shipped
- 223 endpoints across 26 feature areas over 53 tables, built in eight weeks
- Permission model with 54 role templates across four levels of customer hierarchy
- Two separate payment planes — customers taking their own money, and platform subscriptions
- Per-customer encryption keys, so one tenant's data cannot be read with another's
- Accessibility audit scored 100/100 across 18 pages with zero open issues
- Four environments on automated release, with five-minute point-in-time recovery
The situation
Organizations that run events — leagues, associations, organizers — mostly operate on a stack of spreadsheets, a booking tool, and a payment link. The scheduling lives in one place, the member list in another, the invoices in a third, and the money somewhere else entirely. Every one of those seams is where things go wrong.
The client set out to replace that with a single platform: build the event, publish the schedule, manage the people, issue the invoices, take the payments. Their customers are small operators, not IT departments — so it had to be self-serve from the first click, with no implementation project standing between signing up and running a real event.
How we framed the problem
Two decisions shaped everything else.
The first was how customers are structured. A single organizer with one recurring event and a national association with regional bodies and hundreds of events are the same product, and both had to feel native. So the model is a hierarchy — an account holds organizations, which hold business units, which hold events — and every capability in the system is defined against a position in that hierarchy rather than against a customer type. Granting someone control of one region, or one event, or everything, is the same operation at a different level.
The second was permissions, which is where multi-tenant platforms usually rot. The naive approach checks "can this user see this record?" at each point of use, and it fails the moment someone forgets a check. We modeled it after Kubernetes instead: roles describe what may be done to what kind of thing, and bindings attach a role to a person at a point in the hierarchy. Access then resolves by walking the tree.
The important part is that this resolves inside the database query. The permission rules compose into the query itself, so a request cannot return a record the user is not entitled to — not because every developer remembered to check, but because the query was never capable of selecting it. Tenant isolation stops depending on discipline.
The system we built
A web application and an API, with four running environments and releases that ship themselves.
Money moves on two entirely separate planes. Customers take payments from their own customers into their own merchant accounts — the platform never holds their funds. Alongside that, the platform bills those same customers for their subscription. These are different money, different obligations, different failure modes, so they run as isolated integrations with separate credentials and separate webhook paths. Conflating them is a class of incident we designed out rather than tested for.
Each customer account gets its own encryption key, used to protect sensitive fields. One tenant's data cannot be decrypted with another's key, so isolation holds at the storage layer and not only in application logic.
Around that: sign-in through a managed identity service, so the client is not storing passwords. Email and payment providers that can be set once at the top of the hierarchy and inherited downward, or overridden by a specific region or event. Infrastructure defined as code across every environment, so a new one is a deployment rather than a project. And a single application image that reads its configuration at startup — the artifact tested in staging is bit-for-bit the artifact that reaches production.
What was hard
Subscriptions had to be rebuilt around what actually happens. The original design treated a customer's plan as one record that gets modified — upgrade it, downgrade it, cancel it. That model generates a long tail of edge cases: partial-period credits landing as stray balances, downgrades that need scheduling for a future date, and races that can leave a customer with two plans at once. We replaced it with a permanent base entitlement plus additive paid ones. Several categories of bug stopped being possible rather than being fixed individually, and the code got smaller.
Refunds had to tell the truth about fees. When a payment is refunded, the processor keeps part of its fee. Assuming a flat percentage is close enough — until support has to explain a number to a customer who has done the arithmetic. The system records the actual fee breakdown for every refund, so the figure shown is the figure that happened.
One search language, two runtimes. Users can filter with a real query syntax rather than a wall of dropdowns. That syntax is defined once as a formal grammar and compiled into both the backend and the browser, so the client validates exactly what the server will execute. The alternative — two hand-written parsers — drifts silently, and the failure looks like a query that passes validation and then errors on submit. Regenerating both from the single grammar is wired into the build, so they cannot fall out of step.
Stack
.NET 10, ASP.NET Core, Entity Framework Core over MySQL, React 19, TypeScript, Azure Container Apps, Azure Key Vault, Bicep, Stripe, GitLab CI.
Where it stands
The platform runs across four environments with automated versioned releases. Accessibility was audited over five passes and closed at 100/100 across all 18 major pages, against a WCAG 2.2 AA target in both light and dark themes — checked as it was built, not retrofitted before launch.