Company questionsGroundworkAmazon

System design · Streams and jobs

Design a toll fare system with Kafka streaming

  • Reported once
  • SDE-2 / L5
  • 2 diagrams
  • 1 source

One candidate write-up. Treat it as a single data point.

The prompt
Design a toll fare system where roadside gantries read vehicle tags, a Kafka-based streaming pipeline pairs entry and exit reads into trips, prices each trip by distance, vehicle class and time of day, and charges the account. Reads can arrive late, duplicated or out of order, and nobody may be charged twice.

A representative prompt in the style reported, not a quote from Amazon. The answer below is written for this site.

Try it first.The answer is covered until you pick. Talk it through against a 45 minute clock, or just read it.

Ask first

Clarify the problem before you draw anything.

Questions first. Is the road a closed system (a read at entry and a read at exit, fare by distance) or are there flat charges at single points, or both? How is a vehicle identified: a radio tag, a camera plate read, or both? Do customers prepay a balance or get invoiced? How late can a read arrive, for example after a gantry loses its uplink? Who handles disputes, and what evidence must we keep? Roughly how many gantries and reads a day?

Scope I will assume: closed system with entry and exit gantries plus a few flat-rate points; the tag is primary and a camera read is a lower-confidence fallback sent to review; prepaid wallets and postpaid invoices share one ledger. Out of scope: gateway integration with banks, roadside hardware, enforcement against vehicles with no tag.

Numbers

A back-of-envelope that decides the shape.

Planning figures for the exercise, not facts about any operator.

  • Reads: 10 million tag reads a day, 10 × 12 = about 120 a second on average, and a 5x rush-hour peak is about 600 a second.
  • Bytes: at 200 B a read, peak is 600 × 200 B = 120 KB a second; a day is 2 GB, a year about 730 GB. Small.
  • Trips: two reads make one trip, so 5 million fares a day, 5 × 12 = about 60 a second, 300 a second at peak.
  • Ledger writes: 300 a second is far below the 5,000 one primary takes.
  • Open trips (state): entries per second × trip time = 300 × 1,800 s (30 minutes) = 540,000 trips in flight at peak. At 200 B each that is 108 MB of state, which fits in memory.
  • Outage replay: a 2-hour uplink loss leaves 10,000,000 × 2 ÷ 24 = about 833,000 reads; draining 5,000 a second takes about 167 s.

The unusual conclusion: throughput does not justify Kafka. Ordering per vehicle, replay, buffering through outages and decoupling the ledger do. A topic of 12 partitions (about 50 reads a second each at peak) is plenty.

The design

Diagrams first, then components, data and the hard parts.

Toll fares at a glanceReads are keyed by tag_id so a vehicle's reads stay in order. The ledger's unique key makes the final write safe.
Gantrystore and forwardIngest APItag-reads topic12 partitions, by tag_idTrip builderstate 108 MB, changelogTariffsversioned by timeRaw archiveobject storageLedgerunique trip+type+revNightly reconcilebatch uploadkey = tag_idpair entry + exittariff_versionfares topiccopy for replayrecompute farescompare12345678
  1. The gantry writes each read to local disk, uploads in batches and deletes only after an acknowledgement.
  2. The ingest API validates and produces to tag-reads, keyed by tag_id, so one vehicle's reads stay in one partition in order.
  3. The trip builder drops repeated read ids, holds the open entry per vehicle and pairs an exit read with it by event time.
  4. Rating is a pure function of the gantries, vehicle class, event times and tariff_version, so a replay gives the same fare.
  5. Fares go to the fares topic; the writer inserts into the ledger with ON CONFLICT DO NOTHING.
  6. The topic is also copied to object storage for reprocessing and audit.
  7. A nightly batch recomputes fares from the archived raw reads.
  8. It compares them with the ledger and sends differences to a review queue.

Open on the whiteboardAll 2 diagrams on one tidy board, with a step-by-step walkthrough.

  1. Edge. Each gantry stores every read on local disk before sending (store and forward), stamping a read id made of gantry_id, an epoch and a local sequence number, and an event time from a clock synced by NTP. It uploads in batches and deletes only after an acknowledgement. This is where duplicates and lateness come from, so they are designed in.
  2. Ingest. A thin API validates and produces to the topic tag-reads, keyed by tag_id (plate for camera reads, flagged low confidence). The key keeps all of a vehicle's reads in one partition, in order, in front of one consumer. Keying by gantry would instead put a busy gantry on one hot partition and scatter a vehicle's trip across consumers. Retention is 7 days, and a copy goes to object storage for reprocessing and audit.
  3. Trip builder (stateful stream job, for example Kafka Streams or Flink). State per vehicle: the open entry (gantry, event time, read id). An exit read looks up the entry and emits a trip. An exit with no entry waits for a bounded time (say 6 hours) in case the entry arrives late; after that it becomes a "missing entry" trip billed by a documented rule and flagged. An entry with no exit for more than 24 hours becomes a "missing exit" case. The state store has a changelog topic, so a restart rebuilds it.
  4. Rating. A pure function from (entry gantry, exit gantry, vehicle class, event times, tariff version) to a fare in minor units. Tariffs are versioned with effective times, and the trip row stores tariff_version, so a price change at midnight never reprices old trips and a replay gives the same answer.
  5. Ledger. Fares go to topic fares, then a writer inserts into ledger_entries(account_id, trip_id, type, revision, amount_minor, created_at) with a unique key on (trip_id, type, revision). The trip id is a deterministic hash of tag id and entry read id (the exit read id for a missing-entry trip). Unique plus ON CONFLICT DO NOTHING makes the write idempotent, which is how a crash and replay cannot double-charge. The balance is derived from entries, and corrections are new adjustment rows, never edits.
  6. Reconciliation. A nightly batch recomputes fares from the archived raw reads and compares them with the ledger. Differences go to a review queue. This is the safety net behind the stream job.

Duplicates. Three layers, cheapest first: the gantry's own sequence numbers; the stream job's dedupe store keyed by read id with a 7-day TTL; and the unique trip id in the ledger. Any layer may fail and the next still holds. Exactly-once inside Kafka (transactional produce and consume) helps the topic-to-topic hops, but the final database write is an external side effect, so it relies on the unique key.

Late and out-of-order reads. Use event time, not arrival time, for pairing and for choosing the tariff. Within one vehicle, a late entry is matched to a waiting exit. If a trip was already closed with the default rule and the real read arrives afterwards, emit a corrected fare and an adjustment entry, and tell the customer. There is no fixed window to close here, so the bounded waits above play the role of a watermark: they trade how long a fare is delayed against how often it is corrected.

Trade-offs

What you chose, and when you would flip.

  • Stream processing vs a nightly batch. Streaming lets a customer see a fare within seconds and lets the wallet block on a low balance. A nightly batch is simpler and cheaper to operate. Pick streaming, with the batch kept as the reconciler; flip to batch only if billing is monthly and nobody needs the balance in real time.
  • Kafka transactions vs an idempotent sink. Transactions give atomic read-process-write inside Kafka but stop at the database. A unique trip id works for every sink and every replay. Pick the idempotent sink; add transactions as well only if duplicates on the intermediate topic are expensive.
  • Local state vs a database lookup per read. Local state with a changelog is fast (no network hop per read) and sized at about 108 MB. A shared database lookup is simpler to inspect. Pick local state; flip to the database if state grows past what a node can hold or you need ad hoc queries on open trips.
  • Wait longer vs bill sooner. A long wait for a missing read gives fewer corrections but delays charges. A short one bills fast and corrects often. Pick a bounded wait of hours; flip shorter if disputes are rare and cash flow matters.

Follow-ups to expect

Where the interviewer pushes next.

  1. A gantry's clock drifts by 90 seconds. What breaks, and how do you detect and correct it?
  2. A vehicle's entry read is missing entirely. How is it billed, and how does a customer dispute it?
  3. The tariff changes at midnight and a trip straddles it. Which price applies and how do you prove it?
  4. A consumer rebalance happens mid-batch. Walk through why nobody is charged twice.
  5. Camera reads misread plates about 1% of the time. How do you keep wrong accounts from being charged?

Where candidates slip

The mistakes this question punishes.

  • Justifying Kafka by volume. 600 reads a second is tiny. Say the real reasons: ordering, replay, buffering and decoupling.
  • Partitioning by gantry so a vehicle's two reads land on different consumers.
  • Using arrival time for pairing and for the tariff.
  • Claiming exactly-once end to end. The database write is an external effect; say what makes it idempotent.
  • Editing ledger rows instead of appending adjustments.
  • Forgetting missing reads. Real roads lose reads, so unmatched entries and exits need an explicit rule.
  • No reconciliation job, so a stream bug is found by customers.
  • Ignoring clock skew between gantries, so event times from different gantries cannot be compared.
  • Hiding the tariff version, so a replay after a price change gives different fares.

Have a go before you read.

Interviews reward the sketch you make cold. Clarify out loud, draw it, then compare with the answer.

Now make it stick

Where this was reported

Public pages only. Companies do not confirm these, and they rotate their questions, so treat each link as one person's account or one list's tag.

Read these chapters first