← cd ~/work

case study · contract · Cushon (NatWest) · pensions

natwest-cushon/

Cushon, a workplace-pension provider owned by NatWest, was breaking its core platform into services. The first was self-service employer sign-up: a new Symfony microservice that takes an employer from company lookup to a signed agreement, then hands over to the core platform by message. I was its second-highest committer and built much of the sign-up flow, front to back.

role senior engineer contract · 133 of 518 commits, 2nd of 11
stack Symfony 6 · PHP 8.1 Messenger · SQS · DynamoDB · Behat
scale 11 endpoints 27 Behat scenarios · ~317 unit tests
dates 2022 May to November 2022

1 · the problem

Sign employers up without a new service reaching into the old database.

An employer setting up a workplace pension has to be checked, give contribution, payroll and direct-debit details, and sign a participation agreement before the employer can exist in the core platform. That flow was moving into its own service as the first step towards a modular architecture.

The new service had to own its own data, talk to the core platform without sharing its tables, and be specified tightly enough that a pensions business could trust it with money.

2 · what I built

An employer-lead service: DDD layers, buses, and a signed agreement at the end.

The service is split into api, application, domain and infrastructure layers, with every action going through a command or query bus. My share was the lead lifecycle and the signing flow, and the portal pages that drive them. Alongside it, I led the migration of multiple master pension trusts.

  • The employer-lead API Create, read, list by user or by intermediary, update and complete a lead, each as a command or query with its own handler.
  • Domain objects for the money Payroll, contributions and direct debit as value objects and enums inside the lead aggregate, so an invalid lead cannot be built.
  • Agreement signing The service builds a signing envelope from the lead with an e-signature provider, stores the embed link and the document fingerprint, and records when signing completes.
  • Hand-over to the core platform Completing sign-up sends a command over a queue to the core platform, which creates the employer and answers on a return queue that links the new account back to the lead.
  • Sign-up pages in the portal The React and TypeScript portal pages for contributions, direct debit, payroll, account creation and signature, wired to the new service.
  • Less to protect Removed password storage from the service entirely, leaving accounts to the platform that owns them.

Inside the service, and what it talks to

Select any box to see what it does. The service’s layers sit inside the dashed box; everything outside is reached through the infrastructure layer.

architecture
selected buses [CQRS] [MESSENGER]

Every action is a command or a query with one handler. The buses are Symfony Messenger with logging decorators, and routing config decides whether a command runs here or travels over a queue.

$ bin/console debug:messenger

3 · infrastructure

Containers on AWS, built and shipped by CI

Select any box. This is how the service shipped; the pipeline and infrastructure code were the platform team’s, and my changes went through them like everyone else’s.

infrastructure
selected Docker platform [ELASTIC BEANSTALK] [DOCKER]

The service runs as containers on Elastic Beanstalk’s Docker platform. Each build is pushed as an application version, and deploy hooks fetch configuration at start-up.

4 · decisions & trade-offs

Every choice had a cost. Here is what it was.

4.1 · Messages, not shared tables

chose
Commands over a queue, answers on a return queue
because
The new service never writes into the core platform’s database, and either side can be down for a while without losing a sign-up.
cost
Eventual consistency: a lead reads as submitted until the platform answers, and both sides must agree on a message schema.

4.2 · CQRS buses for every action

chose
Symfony Messenger command, query and event buses
because
Each handler is small and tested alone, and routing config decides whether a command runs in-process or travels over a queue.
cost
More classes per feature than a plain controller and service.

4.3 · Behaviour written down first

chose
Behat features, full coverage and mutation testing
because
In pensions the behaviour is the contract. Features describe it in plain language, and mutation testing proves the unit tests would notice if the code changed.
cost
Slower to start each change, and mutation runs take time.

4.4 · A document store for one aggregate

chose
DynamoDB through the SDK, no ORM
because
A lead is one aggregate read and written whole, by key. No relational mapping is needed, and capacity scales on its own.
cost
Access patterns, such as listing by user or by intermediary, have to be designed up front.

5 · testing & delivery

Specified in Behat, held to full coverage and mutation testing.

Features describe each journey in plain language: authorisation, the employer-lead steps, the company check and signing. Under them, unit tests cover the domain and the handlers, and Infection mutates the code to check those tests would fail if it changed.

Static analysis ran at two strict levels, and the whole suite ran inside the same containers that shipped.

$ vendor/bin/behat && vendor/bin/infection
Behat
9 features · 27 scenarios
PHPUnit
~317 tests in 90 files
PHPStan
level 6
Psalm
error level 1
mutation testing
Infection, 100% MSI target
pipeline
CircleCI → ECR → Beanstalk

6 · results

$ git shortlog -sn --all | head -2
my commits
133 of 518
rank by commits
#2 of 11
endpoints in the service
11
signing flow
built end to end
portal sign-up pages
wired to the service
passwords stored by the service
none
master trust migrations
led
contract
May–Nov 2022

Carving a service out of a monolith, carefully?

$ curl sala.dev/contact -d "re: natwest-cushon"
NORMAL /work/natwest-cushon available UK · remote tony@sala.dev updated
available Start a project →