Talk techy to me.
A look at how RC Paddock actually works under the hood.
I built the whole thing myself — the Angular frontend, the GraphQL API, the background worker, the Postgres schema, the media pipeline, the deploys, the monitoring. This page is the stuff I'd actually get into in a code review. Why money is stored as integer cents. Why the order lifecycle is a single transition table the API, the worker, and the tests all read from. Why EXIF stripping happens down in the database layer instead of a resolver. What falls over first when traffic spikes, and the things I chose not to build for v1. I'd rather show the trade-offs than pretend there aren't any.
System at a glance
It's one monorepo — an Angular app, a GraphQL API, and a shared TypeScript package in the middle that both sides import. The worker isn't a separate codebase; it's the same API image booted with a flag flipped, so there's nothing to keep in sync between them. In production it runs as six containers on a single four-core box.
static SPA + crawler/SEO routing
GraphQL + live subscriptions
(same image)
The stack, and why each piece
One line on why, not a wall of logos. I've described the infrastructure by what it does rather than naming vendors — the role it plays matters more than the brand on it.
- Angular (zoneless)
- Change detection runs off signals, with no Zone.js in the mix. Updates fire when a signal actually changes, which is easier to reason about and ships a smaller runtime.
- GraphQL (code-first)
- The schema is generated from the typed resolvers, so the API contract and the code that serves it can't fall out of step.
- Postgres + Prisma
- A typed client over a relational core. Money, state, and ownership are enforced by the database, not just trusted to application code.
- A background worker
- The same API image, booted with a flag, runs the job consumers. Image encoding, reminders, and digests all happen off the request path.
- A job queue on a Redis-compatible cache
- One in-memory service does three jobs — cache, pub/sub for live updates, and the work queue. Fewer moving parts to babysit on a small box.
- Object storage + image pipeline
- Originals get their EXIF stripped and are re-encoded into sized variants. The browser uploads straight to storage, so the API never has to move the bytes itself.
- A cookie-based auth library
- Sessions live in httpOnly, SameSite=Lax cookies. A script can't read the token and the browser blocks the cross-site POST, so I don't need separate CSRF middleware.
- Error monitoring + a11y gate
- Self-hosted error tracking in prod, plus an accessibility check that fails the build on any serious violation across every public and signed-in route.
Codified invariants
A lot of keeping a product stable is just making the same call the same way every time. I've written these down as rules the codebase enforces. Each one is a decision I'd stand behind in review, with the reason it earns its place.
Money is integer cents, never a float
Floats drift; cents don't. One rule for the whole stack, with a contract test that fails the build if anything strays from it.
The order lifecycle is one transition table
The API, the worker, and the tests all read the same map of allowed transitions. Nothing hand-rolls a state change off to the side where it can drift.
An order that closed itself is a different state from one a buyer closed
Reviews, disputes, and notifications all care which way an order ended. Collapse the two and you start firing disputes on orders that closed automatically.
The deal is frozen the moment it's struck
Accepting an offer writes a snapshot of the listing onto it, so an old order shows what was actually agreed to — not whatever the seller edited afterward.
One active order, and one pending offer, per buyer and listing
A partial unique index plus a row lock when an offer is accepted. The database stops a double-sell race even if the service code has a bad day.
Migrations are additive-first, because prod has real users
No destructive or row-losing change without a backup and a plan; renames go add → backfill → switch → drop instead of all at once. The SQL is the easy part — the discipline is what bites you.
EXIF stripping is a database invariant, not a hopeful filter
The strip and its flag are written in the same transaction as the resized variants. An un-stripped image has no variants, so it physically can't render — the guarantee holds even if some resolver forgets to check for it.
Image files are deleted where the row is, not by a nightly sweep
Cleanup runs in the same code that replaces or removes a photo, grabbing the storage keys before the row disappears — because once the row is gone, a sweep has nothing left to find the orphan by.
Reuse the primitive; ask before rolling your own
Hand-roll a badge or a select and you skip the theming, the accessibility work, and the shared API — then it quietly drifts from everything else. There's one catalog, and I use it.
New list queries are cursor-paginated
Consistent, with no rows skipped or repeated as the data grows. The few old offset-based queries are frozen where they are, not copied into anything new.
Docs, changelog, and tests move in the same commit as the code
An out-of-date doc is a bug, same as broken code. Updating the doc, adding the changelog line, fixing the tests — that's part of the commit, not a someday follow-up.
War stories
A few specific ones — the symptom, what was actually wrong, and the fix. The bugs I remember are mostly the ones where the obvious cause turned out to be the wrong one.
The deploy 502s that weren't a boot problem
- Symptom
- Every API redeploy dropped about half a minute of 502s. The obvious guess was that the new container wasn't ready yet and traffic was hitting it too early.
- Root cause
- Adding health checks changed nothing, because the app was actually fine. The gap was in the reverse proxy: a window after the old container was removed and before the new one was registered as routable. Nothing was serving for a beat, no matter how healthy the process was.
- Fix
- The health check turned out to be the useful part anyway — it proved the app booted clean, which meant the problem was the router re-registering too slowly. The fix lives at the load-balancer layer, not in the app.
What it taught me — A health check that barely does anything still paid off — it ruled out my code and pointed me straight at the router.
The image worker that took down everyone by being too parallel
- Symptom
- Under image-heavy load the whole site started throwing 502s — for every user, not just the person uploading.
- Root cause
- The image-resize library defaults its thread pool to the host's CPU count. With several jobs each producing several variants, it spun up hundreds of encode threads and starved the co-located API of CPU on a shared box.
- Fix
- Cap each encode to a single thread and handle the parallelism at the job level instead. Less thread-level parallelism actually made encoding around 3× faster, just by leaving the box enough headroom to keep working.
What it taught me — On a shared box, a library that grabs every core by default is a hazard. Going slower per-job left enough room that the whole thing sped up.
The cleanup job that couldn't see its own orphans
- Symptom
- Stored image files were almost never getting deleted. Dead objects just kept piling up.
- Root cause
- The cleanup job looked for files still linked to a database row. But once a row was deleted, its file had no link left — so the join meant to catch orphans never matched the ones that actually were orphaned.
- Fix
- Delete in the same path that mutates the row, grabbing the storage keys before it cascades away, and back that with a grace-period sweep that knows which kinds of file legitimately have no link. The first prod run cleared out 188 dead objects.
What it taught me — The thing you're trying to clean up is exactly the thing that lost its row — so you have to delete it while the row is still there.
Recovering a failed page load to the right page
- Symptom
- When a navigation hit a code chunk that no longer existed — a stale tab loading after a fresh deploy — the recovery reloaded the page you were leaving, not the one you were trying to reach.
- Root cause
- The recovery just reloaded the current page, but at that moment "current" was still the old route. The page the user actually wanted was the one the router already had in hand.
- Fix
- Hard-navigate to the intended target instead of reloading, with a one-shot guard and a short same-target window so a genuinely broken chunk can't spin into a reload loop.
What it taught me — Mid-navigation, "the current page" and "the page you wanted" aren't the same thing — and the recovery has to aim at the one you wanted.
Testing & CI discipline
Unit and integration tests on the backend, end-to-end tests driving a real browser, and an accessibility check that fails the build on any serious WCAG violation across every public and signed-in route, each with its own baseline. One thing that's bitten me: the frontend's template type-checking is a separate pass from the plain TypeScript compiler, so a build can pass tsc and still fail the real template compile. And keeping the tests and docs current isn't a guideline here — it happens in the same commit as the code, or the change isn't done.
Scaling: measured, not guessed
I actually load-tested this, so the bottleneck order isn't a guess. The ceiling is API CPU, not the database — Postgres sits under half utilization at the same load. So the cheapest next move is just more API processes on the same box, well before a second server or a load balancer is worth the bother.
- Sustained request ceiling
- ~114 req/s measured 2026-06-04
- First bottleneck
- API CPU (~2 cores) — Postgres sits near 37% measured 2026-06-04
- Production footprint
- 6 containers · 1 box · 4 cores / 16 GB measured 2026-06-04
- Backend surface
- 22 GraphQL domain modules · 11 background queues measured 2026-06-21
- Accessibility gate
- 26 routes checked — build fails on any serious violation measured 2026-06-21
Honest scope: dormant vs cut
A few things here are deliberately stubbed, and I'm not going to pretend otherwise. Automated image moderation just returns a pass right now — a manual review queue covers v1. Distance-based search got cut and falls back to newest-first. The full-text search indexes exist, but the query still uses a simpler match for now; the wiring is there, I just haven't flipped it over yet. Video plays from the source file with no transcode. And the whole thing is a facilitator by design — it hosts the listing and the chat, and stays out of payments and disputes on purpose. None of that is missing by accident; it's stuff I parked so v1 could ship.
That's the tour. The changelog is the running list of what's shipped, and about is the why-it-exists-at-all. If you build things too and want to compare notes, I'm easy to find.