Designs by Duhart All 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.


One activity log, three independent readers
Workerno TCP socketsPOSTPostgres outboxsame transactioncommitRelay (Go)host-residentpoll → produceKafka topic6 partitions, key=actorFeed materializer→ CassandraNotification worker→ PostgresRealtime fanout→ WebSocket3 consumer groups

The Worker cannot open a TCP socket, so the event is written to a Postgres outbox inside the post’s own transaction and a host-resident relay moves it onto Kafka. Each consumer group absorbs at-least-once delivery with its own named idempotency mechanism. Nothing here claims exactly-once.

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