← Work

SFU Blueprint

A membership platform for a poverty-reduction nonprofit, built by a team of six that I led.

310ms
lookup p95, down from 820ms
1,000+
monthly active users
6
developers I led

Technical Lead · Jan to Apr 2026

Java, Spring Boot, PostgreSQL, AWS EC2, RDS, S3, SQS, Terraform, GitHub Actions

sfublueprint.org ↗

What it is

SFU Blueprint builds free software for nonprofits. This term we built a membership platform for the Richmond Poverty Reduction Coalition, which coordinates several partner organizations: member registration, profiles, organization membership, document uploads and notifications. It has 1,000+ monthly active users. I was the technical lead for a team of six developers.

What shaped the design

Most decisions came from who was building and running it, more than from traffic:

  • Six part-time student volunteers with different experience levels, for one term.
  • No on-call rotation. If something breaks at 2am, nobody gets paged.
  • A different team inherits the code next term, so anything that lives only in someone's head, or only in the AWS console, is lost.
  • A nonprofit budget, so ongoing cost and operations had to stay minimal.

So the question was: what's the simplest system six part-time people can build in parallel, and the next team can run without us?

Why three services

We split the backend into three Spring Boot services for the team, not for the load. Six developers in one codebase for one term would have collided constantly, so each pair owned and deployed one service. It's more services than the load needed. The boundaries also match failure domains: if notifications break, people can still sign up.

Membership platform architecture
Membership platform architectureA web client calls three Spring Boot services on AWS: a members service for signup, profiles and approvals, an access service for sign-in and roles, and a files and notifications service. The members and access services use PostgreSQL on RDS. Uploads go straight from the browser to S3 with pre-signed URLs, and the files service queues async jobs on SQS.AWSWeb clientmau=1,000+Members servicesignup · approvalsAccess servicesign-in · rolesFiles + notificationsuploads · async jobsPostgreSQLRDSlookup_p95=820ms→310msSQS queueasync jobsS3member filesHTTPS1SQL2upload with a pre-signed URL3enqueue4
  • client
  • service
  • database or storage
  • queue or stream
  • request
  • async: event, message, webhook
Services are named by what they do.
  1. The web client calls the service it needs over HTTPS.
  2. The members service reads and writes PostgreSQL on RDS. This is the lookup path we made fast.
  3. File uploads go straight from the browser to S3 with pre-signed URLs, so big files never touch the app servers.
  4. The files and notifications service puts slow work on an SQS queue and handles it in the background.

Making lookups fast

In a load test that simulated a registration drive, about 50 people signing up at once, the member lookup path had a p95 of 820ms. On data this small, that meant something structural. The code loaded an organization's members, then fetched each member's assignment one at a time: the classic N+1 problem, 51 queries instead of one. And the columns it filtered on had no index, so each query was a scan.

Before and after
Before: one query per memberThe service loads an organization's members, then looks up each member's organization assignment one at a time. With 50 members that is 51 queries, each scanning an unindexed table.before: N+1Members servicePostgreSQLmembers in org50 membersassignment for member 1assignment for member 2... member 5051 queries, no indexAfter: one joined queryThe service fetches members and their assignments in one joined query, and a composite index on organization and member ID makes it an index scan.after: batch fetchMembers servicePostgreSQLmembers JOIN assignmentsindex on (org_id, member_id)50 rows1 query
  • service
  • database or storage
  • request
The fix: one joined query, plus a composite index on (org_id, member_id).

Batch fetching and the composite index brought p95 to 310ms, about 2.6 times faster.

Uploads that skip the servers

Members upload documents like IDs and forms. Streaming those through a Spring Boot service ties up threads and memory on every large file. With pre-signed S3 URLs, the service only signs a short-lived permission, and the file goes straight from the browser to S3.

Uploading a document
Uploading a documentThe browser asks the files service for permission to upload. The service checks the user, file type and size, then returns a pre-signed S3 URL that expires in minutes. The browser uploads the file straight to S3 and confirms, and the service saves the object key on the member's record.BrowserFiles serviceS3upload request (type, size)1check user, type, size2pre-signed URL (expires soon)PUT file directly3confirm (object key)4save key on member5
  • client
  • service
  • database or storage
  • bold outline = what I owned
  • request
  1. The browser asks to upload, with the file's type and size.
  2. The service checks the user, the file type and the size limit.
  3. The browser uploads the file directly to S3 with the pre-signed URL.
  4. It confirms the upload with the object key.
  5. The service saves the key on the member's record.

SQS over Kafka

Notifications fit a queue well: registration publishes "member joined" and the notification service sends emails at its own pace. Our load projections were far below what justifies running a broker, and a volunteer team with no on-call rotation shouldn't be running one.

KafkaSQS
Our peak loadA few hundred messages in a burstSame
OperationsBrokers, partitions, monitoring, upgradesNone, it's managed
Replay, many consumersExcellentNot needed here
Cost at our volumeAn always-on clusterPay per message, close to free
How the two options compared for this project.

SQS also gives retries and a dead-letter queue with nothing to operate.

Deploys and rollback

Every AWS resource (the VPC, EC2 instances, RDS, S3 buckets, SQS queues, load balancer and IAM roles) is defined in Terraform. When the team changes next term, the new team runs terraform plan and sees exactly what exists.

GitHub Actions builds and deploys every change. Each release has to pass a health check, and if it doesn't, it rolls back on its own, which matters when nobody is on call. A bad deploy is rolled back in minutes.

Deploy pipeline with automatic rollback
Deploy pipeline with automatic rollbackA commit to main builds and runs the JUnit and API tests in GitHub Actions, Terraform applies the infrastructure and the release deploys, then a health check gates it. A healthy release goes live. A failed health check rolls back to the previous version automatically.Commitmain branchBuild + testJUnit, API testsDeployTerraformHealth checkLivenew versionRollbackprevious version123pass4failrestore
  • service
  • scheduled job
  • bold outline = what I owned
  • request
  • failure path
  1. A merge to main starts the pipeline.
  2. GitHub Actions builds the services and runs the JUnit unit tests and API integration tests.
  3. Terraform applies any infrastructure changes and the new version deploys to AWS.
  4. A health check gates the release. Only a healthy version goes live.

When it fails

  • A failed health check rolls back to the previous version automatically.

The tests cover the parts that hurt most when they break: membership approvals, access control, and requests that should be rejected.

Key decisions

DecisionInstead ofWhy
Three services, split by ownerOne monolithThree pairs could build and deploy on their own
Batch fetch and a composite indexA bigger databaseThe cause was the query's shape, not capacity
Pre-signed S3 uploadsUploading through the APILarge files never touch the app servers
SQSKafkaLoad far below a broker's threshold; nothing to run
TerraformSetting up in the consoleThe infrastructure survives team turnover
Health-checked deploys with rollbackManual deploysSafe with nobody on call
The main choices, and what they replaced.

Results

lookup p95
820ms → 310ms
speedup
2.6x
monthly active users
1,000+
team
6