Community & Local Services Case study
KEYS Community
A community platform connecting residents, property operators, councils and service providers across engagement, amenities, events and local services.
Community & Local Services
KEYS Community KEYS Community
Technical Lead
System map · Platform topology
Engineering scope
- Five audiences, one dataset Residents, operators, councils, providers and administrators
- Technical lead Direction and production delivery across web, backend and mobile
Platform topology
Who acts
Resident
Operator
Council
Community platform
One NestJS API behind every client
Client surfaces
Resident mobile
Community web
Admin console
Notification service
Email, SMS and push, off the request
Shared infrastructure
- PostgreSQL
- Redis
- RabbitMQ
- AWS

Engineering
NestJS · Next.js · Flutter · PostgreSQL · Redis · RabbitMQ · AWS
Public product · High-level architecture only
- Role
- Technical Lead
- Disclosure
- Public product · High-level architecture only
- Links
- Product website
The problem
What the system had to solve.
A community and local-services platform needed coordinated architecture across resident, operator, council, service-provider and administrative workflows.
What I owned
The responsibility behind the work.
Served as Technical Lead across the web, backend and mobile ecosystem, owning technical direction and production delivery.
Engineering approach
How it was built.
Led Next.js and NestJS development, PostgreSQL architecture, property and address integrations, operator and council workflows, service classification and AWS infrastructure.
Platform layers
- Client
- API
- Services
- Queue
- Data
Five audiences over one dataset
The modelling problem underneath the product.
A community platform reads like a social product from the outside. The engineering problem is that five different kinds of people act on the same records, and each of them is entitled to a different view of it. A resident sees their building. A property operator sees the buildings they manage. A council sees the area it is responsible for. A service provider sees the work offered to it. An administrator sees the configuration behind all of them.
Every feature therefore has to answer two questions before it can answer the first one: who is asking, and what are they entitled to see. Get that wrong once and a resident sees another building’s documents.
Who acts on the platform
- Residents
- Property operators
- Councils
- Service providers
- Administrators
Authorization is not a layer added over the features. It is the first thing every request resolves, and it is the reason the domain model is shaped the way it is.
Four clients, one API
A separate application for each audience.
The audiences do not share an interface, because they do not share a job. Residents live in a phone app. Operators and councils work in the browser. Service providers have their own portal. Administrators have a console. All four are clients of the same API, which is what keeps one set of rules behind four front doors.
Client applications
- NestJS REST API One set of domain rules
- Resident mobile Flutter, iOS and Android
- Community web Next.js
- Vendor portal Next.js
- Admin console React and Vite
One Flutter codebase covers both mobile platforms, so a resident feature ships to iOS and Android together rather than twice, on two schedules, with two sets of bugs.
Two services, one queue boundary
Where the system is split, and why there.
The backend is a TypeScript monorepo holding two deployable services. The API server owns requests, domain logic and persistence. A second service owns everything that leaves the system as a message to a human. They share entity definitions and message types through workspace libraries, and communicate over RabbitMQ.
The split
API service
Owns the request path.
- Authentication and authorization
- Community domain logic
- PostgreSQL persistence
- Redis caching
- Publishes domain events
Notification service
Owns delivery to people.
- Consumes queued events
- Selects a channel strategy
- Email, SMS and push delivery
- Fails without touching the API
- Scales on its own volume
The split is drawn where the latency profile changes. A push notification depends on Firebase, an SMS on Twilio, an email on a delivery provider. None of that belongs inside the request a resident is waiting on.
Five ways in, one identity
Authentication as a strategy, not a branch.
Residents arrive by whichever route is easiest for them, and a community platform cannot dictate that. The system accepts email and password, phone with an SMS one-time code, and three social providers. Each is a separate strategy behind one factory, so adding a route is a new strategy rather than another branch in a login function that already has four.
Supported routes
- Email and password with verification
- Phone with SMS one-time code
- Apple
Token handling
- Credential or provider check
- Identity resolved to one user
- Short-lived access token issued
- Refresh token issued
- Access token renewed without re-authenticating
Role-based access control
What a request is allowed to do.
Roles alone are too coarse for five audiences, so the model separates the role a person holds from the individual permissions that role grants. Route handlers declare the permission they require; a guard resolves the caller and refuses anything that does not carry it. The declaration sits next to the handler, which is the only place a reader will look for it.
Request authorization
- Request arrives with an access token
- Token verified, caller resolved
- Guard reads the required permission
- Caller’s role and permissions checked
- Handler runs, or the request is refused
Committees add a second axis: a resident can hold a seat that grants powers inside one community without granting them anywhere else. Scope travels with the permission, not just with the person.
The community domain
What residents and operators actually do.
The domain is wide rather than deep, and that is the point: a community is not one workflow but many small ones that all have to agree about membership, permission and notification. Modules are organised by domain rather than by technical layer, so a feature lives in one place instead of being spread across four.
Domain areas
Engagement
- Posts with mentions
- Threaded comments
- Reactions
- Polls and voting
- Announcements
Community operations
- Events, tickets and scheduling
- Documents and folders
- Committees, seats and proposals
- Important contacts
- Communal services
Each area carries its own membership and permission rules against the same identity model.
Behind them sits a persistence layer of sixty-odd entity models under versioned migrations. The migration history matters more than the entity count: a live community platform cannot take a schema change that drops data, so every structural change ships as a reviewed, ordered migration.
From domain event to notification
Side effects leave the request path.
When something happens that people should hear about — a post, a proposal, an event, a reply — the domain does not send anything. It emits an event. A listener turns that into a queued message, and the notification service decides what that means for each channel and each recipient’s preferences.
Notification pipeline
- Domain action A post, proposal, event or reply
- Event emitted in-process
- Listener publishes to the queue
- RabbitMQ
- Notification service consumes
- Channel strategy selected
- SMS
- Push notification
A resident posting to their community does not wait on three third-party providers to respond, and a provider outage does not fail their post.
Three channels, one contract
Adding a channel without touching the domain.
Each delivery channel is a strategy behind a common interface, chosen by a factory from the event type. The domain knows that something notable happened. It does not know that push exists, and it does not need changing when a channel is added or a provider is swapped.
Where the knowledge sits
The domain knows
- That an event occurred
- Who it concerns
- Nothing about channels or providers
Delivery knows
- Which channels apply to this event
- Each recipient’s notification preferences
- Device tokens for push
- How to talk to each provider
Delivery surface
- Transactional email
- SMS and one-time codes
- Push to registered devices
- Per-user notification preferences
Property and address intelligence
A postcode is not an address.
A community platform is anchored to physical places, and UK address data is not a solved problem you can look up in one call. A postcode covers many properties. Property identity, coordinates, what sits nearby and how a location should be classified come from different sources that disagree with each other.
That work runs as its own Python service rather than inside the API, because it is I/O-bound against several third parties, its answers are cacheable, and it should never be on the path of a resident opening the app.
Location resolution
- Postcode or property query Entry point
- Fetchers Independent upstream sources
- Processors Property, location and classification
- Enrichment Combine into one answer
- Structured location record
Multiple independent sources mean disagreement is normal, not exceptional. The processing stage exists to decide what to trust, rather than to take whichever answer arrived first.
Service classification
Sorting local services without a person doing it.
The platform connects residents to local services, which only works if those services are categorised consistently. Free-text descriptions from providers are not consistent. Classification runs as a separate Python service that assigns a category and sub-category to a service or business, so the directory stays navigable as it grows.
Classification path
- Service or business description
- Normalisation
- Category and sub-category assignment
- Directory and search surfaces
Both this and the address service are Python, deliberately. Data and model work sits in the language suited to it, and talks to the TypeScript platform over an interface rather than being forced into it.
Data and delivery
What holds state, and what is disposable.
PostgreSQL holds the platform’s transactional state under a versioned migration history. Redis carries what is expensive to recompute and safe to lose. Files live in object storage rather than in the database. The distinction matters operationally: losing the cache is an inconvenience, and everything else has to be recoverable.
Where state lives
Authoritative
- PostgreSQL
- Versioned migrations
- Object storage for files
Derived and disposable
- Redis cache
- Queued messages in flight
- Client-side state
The whole system is containerised, which is what makes a four-client, three-service, two-language platform something a developer can run end to end on one machine before changing any part of it. That is a delivery property as much as a technical one: the cost of a change is dominated by how hard it is to see the effect of it.
Holding direction across the ecosystem
What technical lead meant here.
The hard part of this system is not any one service. It is that a change to membership or permission touches a Flutter app, three web clients, an API, a notification service and two Python services, each of which can be right on its own and wrong together.
One contract, four clients
Rules live in the API rather than being reimplemented per client, so four applications cannot disagree about who may do what.
Shared libraries over duplication
Entity and message definitions are shared across services in the monorepo, so a change to a message shape breaks the build rather than production.
Domain-shaped modules
Modules are organised by domain rather than technical layer, so a feature is found and changed in one place.
Patterns chosen for change
Strategy and factory where new variants were expected — auth routes, notification channels — so growth is additive.
Right language per problem
TypeScript for the product surface, Python for address and classification work, rather than one language everywhere.
Automated API testing
A separate API test suite exercises the contract the four clients depend on.
Engineering scope
What I worked on.
- Technical leadership across four client applications and three services
- NestJS monorepo: API service and notification service over RabbitMQ
- Role and permission model across resident, operator and council access
- PostgreSQL schema and migration history under TypeORM
- Property, address and postcode intelligence service
- Machine-learning service classification for the local-services directory
- Containerised delivery and AWS infrastructure
Why these decisions mattered
The trade-offs behind the architecture.
Notifications as a separate service
Monorepo with shared libraries
Permissions, not just roles
One Flutter codebase for mobile
Python for data services
Containerised end to end
Impact
The verified result.
Established technical continuity across a broad multi-surface product and its operational systems.
Technology
The technical surface.
Clients
- Flutter
- Next.js
- React
- Vite
API
- NestJS
- TypeScript
- REST
- JWT
Data
- PostgreSQL
- TypeORM
- Redis
Messaging
- RabbitMQ
- Event-driven services
Data services
- Python
- FastAPI
- Classification models
Delivery
- AWS
- Docker
- Object storage