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

  1. Who acts

    Resident

    Operator

    Council

  2. Community platform

    One NestJS API behind every client

  3. Client surfaces

    Resident mobile

    Community web

    Admin console

  4. Notification service

    Email, SMS and push, off the request

Shared infrastructure

  • PostgreSQL
  • Redis
  • RabbitMQ
  • AWS
Product surface
The public KEYS Community site, showing its local-services navigation and mobile apps.

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

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

  1. Client
  2. API
  3. Services
  4. Queue
  5. 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

  1. NestJS REST API One set of domain rules
    1. Resident mobile Flutter, iOS and Android
    1. Community web Next.js
    1. Vendor portal Next.js
    1. Admin console React and Vite
Four independently deployed clients against one NestJS API. The contract lives in the API, not in each client.

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
A monorepo with shared model and type libraries, so the two services cannot drift on the shape of a message or an entity.

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
  • Google
  • Apple
  • Facebook

Token handling

  1. Credential or provider check
  2. Identity resolved to one user
  3. Short-lived access token issued
  4. Refresh token issued
  5. Access token renewed without re-authenticating
A short-lived access token for API calls, exchanged with a longer-lived refresh token, so a stolen access token expires quickly and a signed-out device stops working.

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

  1. Request arrives with an access token
  2. Token verified, caller resolved
  3. Guard reads the required permission
  4. Caller’s role and permissions checked
  5. Handler runs, or the request is refused
The check runs before the handler, so a domain method never has to ask whether its caller was allowed to reach it.

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

  1. Domain action A post, proposal, event or reply
  2. Event emitted in-process
  3. Listener publishes to the queue
  4. RabbitMQ
  5. Notification service consumes
  6. Channel strategy selected
    1. Email
    1. SMS
    1. Push notification
Simplified public-safe flow. The domain publishes what happened; the delivery service decides what to do about it.

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
The dependency points one way. Delivery knows about the domain; the domain does not know about delivery.

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

  1. Postcode or property query Entry point
  2. Fetchers Independent upstream sources
  3. Processors Property, location and classification
  4. Enrichment Combine into one answer
  5. Structured location record
Fetch, process, enrich. Each stage is separable, so a failing upstream degrades one part of the answer rather than the whole of it.

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

  1. Service or business description
  2. Normalisation
  3. Category and sub-category assignment
  4. Directory and search surfaces
Kept out of the request path for the same reason as address resolution: it is model work, not API work.

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.

  1. Technical leadership across four client applications and three services
  2. NestJS monorepo: API service and notification service over RabbitMQ
  3. Role and permission model across resident, operator and council access
  4. PostgreSQL schema and migration history under TypeORM
  5. Property, address and postcode intelligence service
  6. Machine-learning service classification for the local-services directory
  7. Containerised delivery and AWS infrastructure

Why these decisions mattered

The trade-offs behind the architecture.

  • Notifications as a separate service

    Decision
    Delivery runs as its own service behind a queue.
    Why
    Three third-party providers should not sit inside a request a resident is waiting on, and their failures should not become the product’s failures.
  • Monorepo with shared libraries

    Decision
    Both services share entity and message-type libraries.
    Why
    A message contract that lives in one place is checked by the compiler instead of discovered in production.
  • Permissions, not just roles

    Decision
    Roles grant granular permissions; handlers declare what they need.
    Why
    Five audiences cannot be expressed as five roles without collapsing distinctions that matter to residents.
  • One Flutter codebase for mobile

    Decision
    A single mobile codebase serving iOS and Android.
    Why
    Resident features ship to both platforms on one schedule, which matters more than platform-native polish for this product.
  • Python for data services

    Decision
    Address resolution and classification are separate Python services.
    Why
    Both are I/O-bound or model-driven, cacheable, and wrong to place on the request path of a product API.
  • Containerised end to end

    Decision
    The whole platform runs under Docker Compose locally.
    Why
    A change to a shared contract can be seen across every service before it is merged.

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

Next: Projul

Back to all work Start a conversation