WorkThe .NET year
GatherGrove
Ran membership, events, payments, and email campaigns for community clubs, keeping every club's member data walled off from every other club's by design.
- Ran on
- Railway (API), Cloudflare Workers (web, via OpenNext)
- Ran
- May 2025 to July 2026
- Domain
- Community and membership management
That platform choice is part of a longer migration: Azure, then Railway, then Cloudflare, across the whole estate. Read how and why.
Built with
- .NET 9
- EF Core 9
- PostgreSQL
- Redis
- SignalR
- Stripe Connect
- Next.js 15
- React 19
- React Native / Expo
- Cloudflare Workers
Hosted services
- Neon (PostgreSQL)
- Redis
- Stripe Connect
- Resend
- Sentry
- PostHog

The decision
Tenant isolation between clubs was enforced at the edge instead of inside EF Core. The usual answer
for a multi-tenant app is a global query filter, HasQueryFilter, that scopes every query to the
caller’s tenant automatically. A filter that silently fails to apply, because a query used raw SQL
or bypassed the entity, leaks data with no signal to catch it.
Instead, a single globally registered MVC action filter, ClubIdValidationFilter, ran ahead of
every one of the 65 controllers’ handlers and logged a cross-tenant attempt as a security event
rather than letting it fall through to an empty result set. It compared the route’s {clubId}
against the caller’s JWT claim, and authorization policies plus composite (ClubId, …) unique
constraints backed it up as defense in depth.
The tradeoff: the filter’s protection stopped at the route, so a query that filtered incorrectly
inside its own handler, the one case HasQueryFilter would have caught automatically, could still
leak. What it bought instead was a check that ran before any of the 65 handlers executed and turned
a cross-tenant attempt into a logged security event rather than a silent empty response.
Architecture
A .NET 9 API in four backend projects with inward-pointing dependencies served three clients: a Next.js 15 web app on Cloudflare Workers, a React Native mobile app, and background jobs. The API talked to PostgreSQL, Redis, Stripe Connect, and Resend, and streamed live updates over three SignalR hubs.
The database itself changed mid-project: a live cutover from SQL Server, which GatherGrove had run
on for its first nine months, to PostgreSQL on 2026-02-17. The type mismatches surfaced in a day.
Threading DateTimeKind.Utc through six services, so that PostgreSQL’s timestamptz (which
rejects a DateTime with an Unspecified Kind that SQL Server had accepted silently) agreed
with the code that talked to it, took five weeks.
By the numbers
Each figure carries where it came from and when it was measured. Open one to see the command behind it, where the repository recorded a command.
- Production code (backend, web, mobile, shared tokens)
- 331,181 lines
Provenance
Source portfolio/METRICS.md § Code volume: verified
cloc backend/src client/src mobile/src shared/design-tokens --exclude-dir=node_modules,.next,bin,objMeasured
- Test code
- 463,430 lines
Provenance
Source portfolio/METRICS.md § Code volume: verified
Measured
- Backend tests and coverage (NUnit)
- 6,193 tests passing
Provenance
Source README.md § test table
Measured
- Web client tests and coverage (Jest)
- 10,932 tests passing
Provenance
Source README.md § test table
Measured
- Mobile tests and coverage (Jest / RNTL)
- 5,605 tests passing
Provenance
Source README.md § test table
Measured
- API controllers
- 65
Provenance
Source portfolio/METRICS.md § Surface area: verified by direct count
find backend/src/GatherGrove.API -iname "*Controller.cs" | wc -lMeasured
- HTTP endpoints and database tables
- 384 endpoints, 84 tables
Provenance
Source README.md § By the numbers: Surface area
Measured
- Commits
- 2,130 across 150 active days
Provenance
Source portfolio/METRICS.md § Commit history
Measured
Screens




Its portfolio/ directory holds the architecture, metrics and testing documents these figures were taken from.