Design a company data platform
Every product team wants answers from data: which feature people use, why revenue dipped, what to recommend next. A data platform collects events from apps and changes from production databases, keeps them cheaply in one place, turns them into trustworthy tables, and serves dashboards, analysts and machine-learning jobs, without ever slowing down production.
1. The prompt
"Design the data platform for a large consumer company: collect app events and data from our production databases, and make it available for dashboards, ad-hoc analysis and machine learning."
What makes it hard:
- Many sources, one truth. Hundreds of databases and dozens of apps, each changing its schema on its own schedule.
- Correctness you can't see. A dropped event or a double-counted order doesn't crash anything; it just makes a dashboard quietly wrong.
- Cost grows with every question. Storage is cheap, but scanning petabytes for each query is not.
2. Frame the problem
Which sources?
Product events from web and mobile apps, plus changes from ~200 production databases (orders, users, payments), and a few SaaS tools (CRM, billing).
Two very different ingestion paths: an event pipe and change data capture (CDC) from databases.
How fresh must data be?
Most dashboards: by the next morning. A few (fraud, live operations): within minutes.
Mostly batch, with a streaming path only where minutes matter. Don't make everything real time.
Who uses it?
~500 analysts writing SQL, ~2,000 dashboards, data scientists training models, and finance reports that must reconcile to the cent.
Different workloads compete for the same compute; finance needs exact, auditable numbers.
How long do we keep data?
Raw events 2 years, curated tables for years, some finance data 7+ years.
Long retention favours cheap object storage over database storage.
Personal data?
Yes: users can ask for their data to be deleted, and only some teams may see emails or addresses.
Deletion in files that are normally never rewritten, plus column-level access control.
Build or buy?
Assume we design the architecture; managed services (a cloud warehouse, managed Kafka) are allowed.
The interview is about data flow, correctness and cost, not about writing a query engine.
3. What it must do
- Ingest app events and database changes continuously, without load on production databases.
- Store everything raw and cheaply, then transform it into clean, documented tables (daily active users, orders, revenue).
- Serve SQL queries, dashboards and ML training jobs, plus a small set of minute-fresh metrics.
Not now: a feature store for real-time model serving, reverse ETL back into product tools, a data marketplace for external customers.
4. How well it must do it
| Target | Why this number |
|---|---|
| No silent loss: every event and change lands exactly once in curated tables data quality · consistency | Revenue and user counts drive decisions and finance reports. |
| Freshness: daily tables ready by 06:00; the fast path under 5 minutes speed | Morning reviews use yesterday's numbers; fraud and operations need minutes. |
| Zero impact on production: ingestion adds no meaningful load to source databases surviving failures | Analytics must never cause an outage. |
| Interactive queries: typical dashboard query under 10 s speed · load shape | Analysts iterate; slow queries mean fewer questions asked. |
| Privacy: deletions complete within 30 days; personal columns visible only to approved roles security | Regulation (for example the GDPR) and user trust. |
5. Numbers that change the design
| Quantity | Working | → so we… |
|---|---|---|
| App events | 2 B a day × ~1 KB of JSON ≈ 2 TB a day, ~25 K a second on average, 3–5× at peak | → so a partitioned log (Kafka-like) absorbs bursts in front of the lake |
| Stored history | 2 TB × 730 days ≈ 1.5 PB raw; columnar and compressed, roughly 5–10× smaller | → so object storage with a columnar file format, not a database's own disks |
| Database changes | 200 databases, ~50 M row changes a day | → so read their replication logs (CDC) instead of running nightly SELECT * dumps |
| Queries | 2,000 dashboards refreshed a few times a day, plus ad-hoc queries | If each scanned a full year it would be petabytes a day → so partitioning, column pruning and pre-aggregated tables decide the bill |
6. The nouns
- Event (from an app) and change (insert, update, delete from a database), each with a unique ID and an event time.
- Table in three layers: raw (as received), clean (deduplicated, typed), marts (business tables such as daily revenue).
- Job: a transformation from input tables to an output table, run by an orchestrator in dependency order.
- Schema and data contract: what a producer promises to send.
raw.app_events event_id, event_type, user_id, event_time, received_at, payload (JSON) partitioned by event_date clean.app_events event_id (unique), event_type, user_id, event_time, typed columns… partitioned by event_date raw.orders_changes op (c | u | d), before, after, source_lsn, commit_time partitioned by commit_date clean.orders order_id, user_id, status, amount, currency, updated_at current state, merged from changes mart.daily_revenue date, country, currency, orders, revenue small; dashboards read this
7. The contract
Producers and consumers both need interfaces:
POST /v1/events [{event_id, event_type, user_id, event_time, properties}] → 202 (batched by the SDK)
schema registry event_type → versioned schema; incompatible changes are rejected at publish time
SQL endpoint SELECT … FROM mart.daily_revenue WHERE date >= … (dashboards, analysts)
table read API read clean.app_events snapshot @ version N (ML training, reproducible)
job definition inputs, output, SQL or code, schedule, owner, tests, freshness target
8. First working version
App SDKs send batched events to a collector; databases stream changes through CDC; both land in a log, then in object storage as raw tables. Nightly jobs build clean tables and marts; a SQL engine serves queries.
8.1 Getting data in
The collector only validates and appends to the log, so it stays fast and can't lose data in a burst. A CDC connector reads each database's replication log (the same stream a replica uses), so production sees no extra queries. A sink job writes the log into object storage every few minutes as columnar files.
8.2 Turning it into answers
An orchestrator runs transformation jobs in dependency order: clean tables first, then marts. Dashboards read the small marts; analysts can still query the big clean tables when they need detail.
9. Make it hold up
9.1 Batch, streaming, or both freshness
| Approach | Why | |
|---|---|---|
| ✗ Bad | Everything streaming, every table updated in seconds | Most questions are about yesterday; streaming costs more to build, run and debug, and joins across late data get hard. |
| ~ Good | Two separate pipelines: nightly batch for accuracy, streaming for speed (the Lambda architecture) | Works, but the same logic lives twice and the two numbers disagree. |
| ✓ Great | One log as the source; the lake is written in small increments every few minutes; daily jobs read the same tables; a streaming job only for the handful of minute-fresh metrics | One definition of each metric; streaming is a targeted exception, not the default (Module 23). |
9.2 Files on object storage that behave like tables interactive queries
Object storage gives cheap, durable files but no transactions: a job that dies halfway leaves half-written output, and readers can see a mix. An open table format (Apache Iceberg, Delta Lake or Apache Hudi) adds a metadata layer: each commit writes new files, then atomically swaps a pointer to a new snapshot listing them.
- Atomic commits: readers see the old snapshot or the new one, never a half.
- Time travel: query the table as of snapshot N, so ML training and audits are reproducible.
- Small files: writing every few minutes creates thousands of tiny files, and opening files dominates query time. A background compaction job rewrites them into ~256 MB–1 GB files.
- Partitioning by event date (and clustering by common filters) lets a query for "last 7 days" skip the other 358 days entirely.
9.3 Change data capture without surprises no silent loss
- Snapshot, then stream: copy the table once at a known log position, then apply every change after that position, so nothing falls in the gap.
- Order per key: partition the log by primary key, so the changes to one order arrive in commit order.
- Deletes are changes too: keep them (as a delete marker) so the clean table can remove the row.
- Merge into current state:
clean.ordersapplies changes with an upsert by key and keeps the latest by log position, so replaying the same changes gives the same table (idempotent). - Schema changes flow through a schema registry; an added column is fine, a renamed one breaks consumers and needs a contract change (9.4).
9.4 Bad data and data contracts data quality
| Approach | Why | |
|---|---|---|
| ✗ Bad | Find out from a stakeholder that yesterday's revenue is zero | By then, wrong numbers have spread into decisions and other tables. |
| ~ Good | Tests on output tables (not null, unique keys, row counts within a range) | Catches many problems, but after the bad data has landed. |
| ✓ Great | Contracts at the source (registered schemas, owners, compatibility checks in the producer's CI), tests between layers that block publishing a table, a quarantine table for rejected rows, and freshness alerts per table | Bad data is stopped early and visibly; consumers keep the last good version instead of a broken one. |
9.5 Late and duplicate events exactly once in curated tables
Mobile apps send events hours or days late (offline phones), and SDKs retry, so duplicates arrive. Partition by event time (when it happened), not arrival time; deduplicate on event_id when building clean.app_events; and re-run the last few days' partitions each night so late arrivals are included. Daily numbers can change slightly for a few days: say so on the dashboard, and freeze finance numbers only after the late window closes (Module 20).
9.6 Deleting personal data in files that never change privacy
- Keep personal data in few places: store a pseudonymous user ID in events, and emails or addresses only in a small, access-controlled user table.
- Delete by rewriting: table formats support row-level deletes (written as delete files and merged on read, then compacted away), so a weekly job removes the requested users' rows from every table that has them.
- Crypto-shredding for very large or scattered data: encrypt each user's personal fields with a per-user key and delete the key.
- Access control per column and row, enforced by the query engine, and an audit log of who read what (Module 26).
9.7 Query cost and noisy neighbours interactive queries
| Approach | Why | |
|---|---|---|
| ✗ Bad | Dashboards query raw JSON events directly | Every refresh reads every column of every day: slow and expensive. |
| ~ Good | Columnar files partitioned by date | Queries read only the columns and days they need, often 100× less. |
| ✓ Great | Plus compacted files, small pre-aggregated marts for dashboards, separate compute pools (dashboards, ad-hoc, nightly jobs) with quotas, and cost shown per team | A runaway query can't starve the morning dashboards, and teams see what their queries cost. Try it in section 13. |
9.8 The whole design after the deep dives
10. Interviewer follow-ups
A producer ships a bug that sends user_id as null for a day.
The contract test in the clean layer fails, rows go to quarantine, and the table keeps yesterday's good snapshot. After the fix, replay that day from the raw layer (which kept everything) and rebuild downstream tables.
Finance says revenue in the warehouse doesn't match the billing system.
Reconcile daily: compare counts and sums per day and currency between the source and clean.orders, alert on any gap, and trace it with the CDC log positions. Finance tables are frozen only after reconciliation passes.
Add a real-time "orders in the last 5 minutes" metric.
A streaming job reads the orders change stream, aggregates in event-time windows with a watermark, and writes to a small serving store. The daily mart remains the source of truth for history.
ML wants features computed the same way for training and serving.
Define each feature once, compute it in batch for training (reproducible via table snapshots) and in a stream for online serving, and check that the two agree. That's the next step: a feature store.
A source database is migrated to a new cluster.
Snapshot the new cluster at a log position, run both CDC connectors in parallel, compare the resulting tables, then switch. The clean layer hides the change from consumers.
Storage costs keep rising.
Expire old snapshots, move raw data older than 90 days to a colder storage class, drop raw copies once clean tables are verified (if replay isn't needed), and find tables nobody has queried in 6 months.
11. When things break
| Failure | What users see | How we detect it | What limits the damage |
|---|---|---|---|
| Collector or log overloaded | Nothing, if SDKs buffer | Producer errors, log lag | SDKs batch and retry with backoff; the log is replicated and partitioned |
| CDC connector stops | Tables go stale | Freshness alert, connector lag | Resume from the last committed log position; source keeps enough log |
| Nightly job fails | Yesterday's numbers missing | Orchestrator alert, freshness SLA | Retries; dashboards show "data as of" and keep the last good snapshot |
| Bad data in a source | Wrong numbers | Contract and range tests | Publishing blocked; quarantine; replay from raw after the fix |
| Runaway ad-hoc query | Slow dashboards | Queue time in the dashboard pool | Separate compute pools, query time and scan limits |
| Small-file explosion | Slow queries | Files per partition | Scheduled compaction |
12. Wrap-up and the clock
Trade-offs we made:
- Batch by default, streaming only where minutes matter.
- Raw data kept so any table can be rebuilt, at the cost of storage.
- An open table format on object storage instead of one vendor's warehouse storage, for cost and engine choice.
- Daily numbers that may change for a few days, for complete late data.
What we'd watch: freshness per table · rows quarantined · CDC lag · files per partition · bytes scanned per team · dashboard query p95 · reconciliation gaps.
At 10×: more log partitions, more aggressive compaction and clustering, more marts in front of dashboards, and chargeback per team.
A 60-minute interview
| 0–5 | Frame: sources, freshness, users, privacy |
| 5–10 | Must do, how well, numbers |
| 10–15 | Nouns (layers), contract |
| 15–25 | First version: in, store, transform, serve |
| 25–50 | Deep dives: batch vs stream (9.1), table format (9.2), CDC (9.3) |
| 50–60 | Quality or privacy, follow-ups, wrap-up |
A 90-minute interview
| 0–8 | Frame, including finance and privacy |
| 8–15 | Must do, how well, numbers |
| 15–22 | Nouns, contract |
| 22–35 | First working version |
| 35–75 | Deep dives 9.1–9.7 and the final picture |
| 75–85 | Follow-ups: reconciliation, real-time metric |
| 85–90 | Trade-offs, metrics, 10× |
| Level | What a strong answer shows |
|---|---|
| Mid-level | Events through a queue into storage, nightly ETL into a warehouse, dashboards on top, partitioning by date. |
| Senior | CDC instead of dumps, raw/clean/mart layers, deduplication and late data by event time, a table format with compaction, quality tests that block publishing. |
| Staff+ | Data contracts and ownership, reconciliation for finance, privacy deletion in immutable files, cost visibility and workload isolation, choosing where streaming is worth it. |
Common mistakes:
- Nightly
SELECT *from production databases. - Partitioning by arrival time, then wondering why late events land on the wrong day.
- Making every table real time.
- No raw layer, so a bug can't be fixed by replaying.
- Forgetting deletes, both CDC deletes and user deletion requests.
13. Try it, check yourself, read more
Query-cost lab. One year of app events (2 TB of raw JSON a day). A dashboard asks for the last 7 days, 3 of the 40 columns. Change how the table is stored and watch how much data the query has to read. Costs use an illustrative $5 per TB scanned.
Model: columnar files are ~6× smaller than JSON and a query reads only the columns it needs; each file costs ~20 ms to open and plan, spread over 200 parallel readers that each read 200 MB/s.
How do you copy a busy production database into the lake without slowing it down?
Phones send events up to 3 days late. How do daily counts stay right?
What stops readers from seeing half-written output when a job dies midway?
Dashboards are slow and the bill is high. What helps most?
| Resource | Why |
|---|---|
| Designing Data-Intensive Applications (Martin Kleppmann) | Chapters 10–11 on batch and stream processing and derived data. |
| Apache Iceberg | Snapshots, hidden partitioning and row-level deletes on object storage. |
| Delta Lake and Apache Hudi | The other two widely used open table formats. |
| Debezium | Open-source change data capture: snapshots, log positions and delete events. |
| dbt and Apache Airflow | SQL transformations with tests, and dependency-ordered scheduling. |