Designs by DuhartAll work
Rank
1 of 28
Tier
Tier S 103 of 120

1 — Billboard Activity Graph

Rank 1 of 28 · Tier S · app/BillboardActivityService, app/workers/billboard_users Stack: Go 1.25, Kafka (segmentio/kafka-go), Cassandra (gocql), PostgreSQL, Cloudflare Workers Status: Built end-to-end and running. Commit 9d31b7d28.

Why this is ranked first. This is the system design interview. Not like it — it is the exact question ("design a social feed / activity stream") with the exact primitives (outbox, log, consumer groups, idempotency, hybrid fan-out) implemented in production Go against real Kafka and real Cassandra. Every follow-up an interviewer can ask has an answer already written down in the commit message.


The problem

Playlists, follows, feed, and posts are one social graph. The naive build gives each of them its own event pipeline, its own fan-out, and its own consistency story — four systems that drift apart. The directive was one activity log and one materializer.

Complication: the service that writes activity, billboard-users, is a Cloudflare Worker. Workers cannot open raw TCP sockets — not with a library, not with a shim. The Kafka wire protocol and CQL are both permanently unreachable from a Worker.

The architecture

billboard-users (Cloudflare Worker)
      │  writes billboard_feed_events in the SAME transaction as the post
      ▼
Postgres billboard_feed_events                    ← the activity log / outbox
      │  relay: claim → produce → stamp published_at
      ▼
Kafka  billboard.activity.v1   (6 partitions, keyed by actor)
      │
      ├─ group feed-materializer   → Cassandra billboard_feed.feed_timeline
      ├─ group notification-worker → Postgres billboard_notifications
      └─ group fanout              → realtime delivery

Four processes in one binary because they share a database pool and a Cassandra session — not because they are coupled. Each consumer owns its group, so splitting them across hosts later is a deployment change, not a rewrite.

The four decisions worth defending

1. Outbox, not direct produce

A Worker cannot enlist Kafka in a Postgres transaction. Producing directly leaves a window where either a post exists with no event, or an event survives a rolled-back post. Writing the event inside the post's transaction and relaying it afterwards closes that window.

2. At-least-once, with the cost paid explicitly at every sink

The relay produces before stamping published_at. A crash between the two re-delivers. That is deliberate: a lost event cannot be recovered, a duplicate can be absorbed. Each consumer absorbs it differently:

ConsumerIdempotency mechanism
feed-materializerbillboard_feed.applied_activity, Cassandra, 7-day TTL
notification-workerunique index on activity_id + ON CONFLICT DO NOTHING
fanoutrepeated realtime frame dropped client-side

The idempotency key is the outbox row id — stable across re-delivery by construction.

3. Three groups, not three stages

A Cassandra outage must not stop notifications. A notification failure must not keep posts out of feeds. A single shared pipeline does exactly that. Three independent groups off one topic means each can fail, lag, and retry alone.

4. Thin events, not denormalized ones

billboard_feed_events carries ~40 denormalized display columns for the activity-feed UI. None of them are in the Kafka event. A consumer that needs the actor's avatar joins for it. Baking display fields into the event freezes every one of them at write time — and they are wrong the moment a user renames themselves.

What was verified before building

Directive 6 ("feed materialization is a Kafka consumer writing to Cassandra") required a Kafka and a Cassandra reachable from this host. Both were confirmed present before a line was written. Directive 2 was identified as a migration, not a new column — /v1/playlists/{id}/shuffle already rewrites an integer position holding live data.

Where a directive would have required infrastructure that was not present, it was written up as blocked rather than quietly substituted.

Scope

MetricValue
Go source977 lines across 5 internal packages
Kafka partitions6, keyed by actor id
Consumer groups3, independent
Datastores writtenCassandra, Postgres, realtime channel
Delivery semanticsAt-least-once, deduped at every sink

Interview surface this opens