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
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.
- client
- service
- database or storage
- queue or stream
- request
- async: event, message, webhook
- The web client calls the service it needs over HTTPS.
- The members service reads and writes PostgreSQL on RDS. This is the lookup path we made fast.
- File uploads go straight from the browser to S3 with pre-signed URLs, so big files never touch the app servers.
- 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.
- service
- database or storage
- request
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.
- client
- service
- database or storage
- bold outline = what I owned
- request
- The browser asks to upload, with the file's type and size.
- The service checks the user, the file type and the size limit.
- The browser uploads the file directly to S3 with the pre-signed URL.
- It confirms the upload with the object key.
- 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.
| Kafka | SQS | |
|---|---|---|
| Our peak load | A few hundred messages in a burst | Same |
| Operations | Brokers, partitions, monitoring, upgrades | None, it's managed |
| Replay, many consumers | Excellent | Not needed here |
| Cost at our volume | An always-on cluster | Pay per message, close to free |
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.
- service
- scheduled job
- bold outline = what I owned
- request
- failure path
- A merge to main starts the pipeline.
- GitHub Actions builds the services and runs the JUnit unit tests and API integration tests.
- Terraform applies any infrastructure changes and the new version deploys to AWS.
- 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
| Decision | Instead of | Why |
|---|---|---|
| Three services, split by owner | One monolith | Three pairs could build and deploy on their own |
| Batch fetch and a composite index | A bigger database | The cause was the query's shape, not capacity |
| Pre-signed S3 uploads | Uploading through the API | Large files never touch the app servers |
| SQS | Kafka | Load far below a broker's threshold; nothing to run |
| Terraform | Setting up in the console | The infrastructure survives team turnover |
| Health-checked deploys with rollback | Manual deploys | Safe with nobody on call |
Results
- lookup p95
- 820ms → 310ms
- speedup
- 2.6x
- monthly active users
- 1,000+
- team
- 6