Skip to content
Under the hood

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.

01 · Architecture

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.

02 · Choices

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 instead of Zone.js. Updates fire when a signal actually changes, which is easier to reason about and ships less runtime code.
GraphQL (code-first)
The schema is generated from the typed resolvers, so the API contract can't drift away from the code that serves it.
Postgres + Prisma
A typed client over a relational core. Money, state and ownership are enforced by constraints in the database, so a bad day in the service layer can't quietly corrupt them.
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 covers three jobs: cache, pub/sub for live updates, and the work queue. On a small box that's three fewer things to babysit.
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 won't send it on a cross-site POST, which is why there's no separate CSRF middleware.
Eight languages, checked at build time
Every string the site shows you is a key with a translation in all eight dictionaries. A build step diffs them and fails on any key that exists in one language but not the rest.
Error monitoring + a11y gate
Self-hosted error tracking in prod, plus an accessibility sweep that fails the build on serious violations across the public and signed-in routes.
03 · The middle

Contracts as code: the shared layer

The package in the middle of the monorepo holds the single sources of truth: the order state machine as a transition table, the RC spec fields shared across two database models, the helper that builds every image-variant URL, the rule that money is integer cents. The reason this matters is that those cross-cutting rules live in one typed place the resolvers, the worker, and the tests all import, instead of being re-implemented a little differently at every call site. Change a rule once and everything picks it up.

04 · The rules

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 & state
  • Money is integer cents, never a float

    Floating-point money accumulates rounding error, so every amount in the system is a whole number of minor units in the row's own currency. There's 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 and not whatever the seller edited afterward.

Data safety
  • 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

    Nothing destructive or row-losing ships without a backup and a written plan, and a rename goes add → backfill → switch → drop instead of all at once. Writing the SQL is never the hard part. Making yourself do it in four boring steps when one would obviously work is.

Media & privacy
  • 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 image that hasn't been stripped therefore has no variants, and with no variants there is nothing for the site to render. The guarantee holds even if a resolver somewhere forgets to check the flag.

  • 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. Once the row is gone, a sweep has nothing left to find the orphan by.

Frontend discipline
  • Reuse the primitive; ask before rolling your own

    Hand-roll a badge or a select and you skip the theming, the keyboard handling and the shared API, and then it quietly drifts from everything else on the site. There's a catalog of the shared building blocks, and the rule is that I check it before writing a new one.

  • New list queries are cursor-paginated

    Cursors keep a list consistent as data is added under you, with no rows skipped or repeated between pages. The few old offset-based queries are frozen where they are and never copied into anything new.

  • Every visible string is a translation key

    Eight languages, and the build compares the dictionaries so a key can't exist in one and be missing from another. Worth being honest about the hole in that: the check only sees keys, so a string typed straight into a template is invisible to it. That part is on me, not the tooling.

Testing & docs hygiene
  • Docs, changelog, and tests move in the same commit as the code

    An out-of-date doc is its own kind of bug, and the only version of this that has ever held is doing it in the moment. Updating the doc, adding the changelog line and fixing the tests are part of the commit, not a follow-up I swear I'll get to.

05 · When it broke

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.

1

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 earned its keep anyway. It proved the app was up and serving well before the traffic arrived, which is what told me the router was the slow part. The fix ended up living at the load-balancer layer and not in the app at all.

What it taught me — The check I added didn't fix anything, but it ruled out my code, and that was most of the work.

2

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 sizes its thread pool from the host CPU count. With several jobs in flight and several variants per job, that worked out to hundreds of encode threads on a four-core box, and the API sharing that box never got a turn.
Fix
Each encode is now capped to one thread, and the parallelism happens at the job level where I can actually see it. Encoding came out around 3× faster afterwards, purely because the machine had room to breathe.

What it taught me — A library that helpfully grabs every core is a hazard when it isn't the only thing on the machine. Doing less at once made the whole thing finish sooner, which I did not expect going in.

3

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 went looking for files whose database row was gone. It found them by walking the rows. Once a row is deleted there is nothing left pointing at its file, so the orphans it was written to catch were exactly the ones it could no longer see.
Fix
Deletion now happens in the same code that mutates the row, which reads the storage keys while the row is still there. Behind that sits a grace-period sweep that knows which kinds of file are allowed to have no row at all. The first run in production cleared 188 dead objects.

What it taught me — I had written a query to find things by a link that, by definition, no longer existed. Obvious in hindsight, invisible for weeks.

4

The bot rule that locked out real people

Symptom
For about thirteen minutes one afternoon the site loaded its shell and then sat there saying "Loading". Signed in or not, my own admin pages included.
Root cause
Some obviously fake browsers had been crawling the place pages, so I'd put a filter at the edge to challenge them. One clause matched a browser version I was confident no real person could be running. It was the current version of Chrome. Everyone on an up-to-date browser got challenged, and while a page navigation can answer a challenge, the background data requests an app makes after it loads cannot. So the shell rendered and nothing ever filled in.
Fix
Removed that clause and kept the two that match genuine giveaways: a crawler that names itself, and a browser last shipped in 2013. Anything that filters on a version number now gets checked against what real browsers are actually sending before it goes anywhere near production.

What it taught me — Two things I got wrong at once. A version number looked implausible when it was just newer than what I had in my head, and I hadn't considered that the site can pass a challenge on the way in and still fail every request it makes afterwards.

5

The dropdown that opened 360 pixels too low

Symptom
On one page, a filter dropdown opened well below the control it belonged to, floating over the content underneath. The same component was fine everywhere else on the site.
Root cause
That dropdown positions its panel against the viewport on purpose, so it can escape any parent that would clip it. The catch is that a parent carrying a blur or a transform stops behaving like an ordinary parent for that kind of positioning and quietly becomes the box the panel measures itself against. The sticky filter bar on that page had a backdrop blur and a transform hint left over from its hide-on-scroll animation.
Fix
Swapped the blur for a nearly-opaque solid background, which looks the same at a glance, and dropped the transform hint. The standing rule now is that a floating panel doesn't go inside a sticky element carrying either of those.

What it taught me — Neither the dropdown nor the filter bar was wrong on its own. They interacted through a CSS rule that gives you no warning and no error, which is the kind of bug that eats an afternoon.

06 · Discipline

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.

07 · Under load

Scaling: what falls over first

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-07-27
Backend surface
57 domain modules · 18 background queues measured 2026-07-27
Accessibility gate
29 routes swept; the build fails on any new serious violation measured 2026-07-27
Languages shipped
8, with key parity enforced by the build measured 2026-07-27
08 · What I didn't build

Honest scope: dormant vs cut

A few things here are deliberately unfinished, and I'd rather tell you than have you find out. Automated image moderation is a stub that approves everything, with a manual review queue covering it for now. Video plays back from the file that was uploaded, with no transcoding behind it, so a huge clip stays huge. Distance sorting works off a postal-code table rather than real geocoding, so a code it doesn't recognize quietly falls back to newest-first. There's an invite-code table sitting in the database from a plan to launch closed; signup ended up open, and the table has been idle ever since. And the product is a facilitator by design: it hosts the listing and the conversation, and stays out of payments and disputes on purpose. None of that is missing by accident. It's what 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.