E-Commerce Platform Architecture¶
A GDPR erasure request lands for a customer who checked out eleven months ago. Legal wants confirmation within 30 days that the record is gone from every system — not just Postgres. The on-call engineer opens the lakehouse and finds the customer's order rows sitting in twenty different Parquet files across as many partitions, written by a CDC pipeline that only ever appends. Predict before you read on: is this pipeline's append-only design a reasonable trade-off here, or is it the root cause of the incident?
It is the root cause: orders, payments, inventory, users, clickstream, and (later) recommendations all flow through this platform, and the dominant constraint is correctness of mutable facts, not dashboard milliseconds. An order goes PENDING → PAID → SHIPPED → DELIVERED and may be cancelled, refunded, or GDPR-erased. If your lake cannot upsert and delete, you do not have a commerce platform — you have a log of rumours.
Related: lakehouse comparison, Kafka exactly-once, Airflow idempotency.
Requirements¶
| Axis | Target |
|---|---|
| Volume | Start: 50–200 orders/s peak, 2–10k clickstream events/s. 10×: 1k orders/s, 100k clicks/s. Facts are small; events are large. |
| Latency | Checkout path is Postgres, not the lake. Analytics: minutes to an hour is fine for "revenue today." Inventory signals for the website may need seconds. |
| Access | Operational: point-get order by id (OLTP). Analytics: joins orders×items×users. Inventory: current quantity per SKU. Recs: co-purchase (later). |
| Retention | Orders: years (legal). Clickstream: 90 days hot-ish, 1–2 years lake. CDC topics: long enough to rebuild the lake (days to weeks) plus compacted changelog if you need current state. |
| Cost | Do not put clickstream in Postgres. Do not put orders only in ClickHouse. |
| Failure | Duplicate CDC events must not double revenue. A replay must be idempotent. GDPR delete must reach lake + serving + search, not only prod Postgres. |
The lake is not the checkout database
If ClickHouse or Iceberg is on the purchase critical path, you have coupled conversion to compaction. Checkout stays OLTP (Postgres/MySQL). The data platform is a derived system.
Capacity sketch (V1 commerce, not hyperscale)¶
Assume 100 orders/s peak, 5k click events/s, 800-byte clicks, 1.5 KB order CDC payloads.
| Stream | Events/s | GB/day uncompressed |
|---|---|---|
| Order CDC | 100 (plus updates: ×3–8 row events ≈ 400/s) | 400 × 1.5 KB × 86400 ≈ 50 GB/day |
| Clickstream | 5,000 × 800 B × 86400 | ~345 GB/day |
| Inventory CDC | bursty, small | low tens of GB/day |
Kafka: clickstream dominates partitions and disk. Orders need correctness (acks=all, compacted topics for current order state optional).
Partitioning:
ordersCDC: key =order_id(all mutations for an order land in one partition — required for ordered upserts).clicks: key =user_idorsession_id. Watch hot celebrities / bots.- Target 12–48 partitions on clicks at 5k/s; 6–12 on orders.
Serving:
- Postgres: primary, sized for OLTP + CDC slot lag, not 90 days of clicks.
- Iceberg: 345 GB/day clicks × 90 d ≈ 30 TB raw, much less as Parquet.
- ClickHouse (if you add it): last 7–30 days of shop metrics, not the legal order archive.
V1 — fewest parts¶
Goal: reliable analytics on orders + a clickstream lake. No recs, no real-time inventory microservice.
Postgres (orders, payments, users, stock)
→ Debezium
→ Kafka (CDC topics, keyed by PK)
→ Spark Structured Streaming or hourly Spark
→ Iceberg (or Hudi if updates are very frequent)
→ Trino (analyst SQL)
App clickstream
→ Kafka
→ same Spark job family
→ Iceberg partitioned by date
Orchestrate batch models with Airflow + dbt on Iceberg (or on a warehouse if you already have one). That is enough to answer "GMV yesterday" and "funnel by campaign."
What V1 includes¶
- Debezium with a schema registry (Avro/Protobuf). JSON CDC without a registry will break on the first column add.
- Exactly-once or idempotent lake writes: merge on
order_id+ version/ts_ms, or Hudi upsert. - Partition Iceberg clicks by
date. Partition orders byorder_date(business date, not ingest date). - A single CDC pipeline owned by data engineering + DBA (slot lag is a prod incident).
What you would not add yet¶
- Neo4j / GNN recs
- Flink (unless inventory-on-site is a launched product requirement)
- ClickHouse (unless a merchant dashboard has a sub-second SLA)
- Per-shop Kafka clusters
- Dual lakehouses (Iceberg and Hudi and Delta) "to evaluate"
Hudi vs Iceberg in V1
Orders update. Iceberg merge-on-read/copy-on-write can do this; Hudi MoR is purpose-built for high-frequency upserts. If update rate is "status changes a few times per order," Iceberg is simpler and matches Trino/Spark well. If you are merging every item-level change at high QPS into a huge fact table, look at Hudi. See lakehouse comparison. Do not pick Hudi because CDC exists — pick it because upsert volume exists.
This architecture works while… / breaks when…¶
Works while:
analytics latency of minutes-to-an-hour is genuinely acceptable
checkout stays entirely in Postgres — the lake is derived, never load-bearing
CDC volume is a manageable load on the primary (filtered tables and columns)
order updates are occasional status changes, not high-QPS item-level merges
Trino concurrency is analyst-shaped: a handful of people, not an app
GDPR deletes can be satisfied on a batch cadence
Breaks when:
a customer-facing surface needs sub-second answers — an analyst engine cannot serve a product
upsert volume rises to continuous item-level merges into a huge fact table
(the point where Hudi MoR stops being a preference and becomes a requirement)
inventory-on-site becomes a launched feature with a seconds-level SLA — the batch
path cannot express it, and this is what actually buys Flink
Debezium lag starts pushing WAL retention on the primary — the platform is now a
reliability risk to checkout itself
clickstream volume × bad partitioning makes every Trino query a full scan
Note which of these are volume and which are product decisions. The first and third are requirement changes, not growth — they would force the same move at a tenth of the traffic.
Bottleneck at the end of V1¶
| Symptom | Cause | Wrong fix |
|---|---|---|
| Debezium lag / WAL disk on Postgres | Slow sink or too much noise (update-heavy tables) | Bigger Kafka only |
| Revenue double-counts after a job retry | Append-only Parquet, not merge | "Exactly-once Kafka" without idempotent sink |
| Trino queries scan 30 TB clicks for one day | Missing partition predicate / wrong partition column | Bigger Trino cluster |
| GDPR ticket cannot finish | Deletes only in Postgres | Pretend the lake is ephemeral |
| Spark job OOMs on customer join | Skew: one wholesale buyer or one bot user | More executors |
CDC is a load on the primary. If you CDC the entire users table including columns you never query, you pay WAL and Kafka for nothing. Filter at Debezium.
The first analytics bottleneck is usually clickstream volume + bad partitioning, not order volume.
V2 — when a real product needs derived serving¶
Add components only with a named consumer.
graph TD
PG["PostgreSQL OLTP"] --> DEB["Debezium"]
DEB --> K["Kafka\nCDC + clicks"]
WEB["Clickstream"] --> K
K --> FL["Flink\ninventory projection, optional"]
K --> SP["Spark / dbt\nmerges, facts, GDPR deletes"]
SP --> LK["Iceberg or Hudi\nsystem of analytical record"]
FL --> CH["ClickHouse\nmerchant / ops dashboards"]
SP --> CH
LK --> TRI["Trino ad-hoc"]
SP --> NEO["Neo4j recs graph\nbatch, derived"]
NEO --> API["Recs API"]
PG --> CHK["Checkout / payments\nstays on Postgres"] Inventory (seconds)¶
If the storefront must not sell air:
- V1.5: read stock from Postgres (it is already correct). Cache with TTL if read-heavy.
- V2: Flink projects
sku → quantityfrom CDC into Redis/Cassandra or a compact Kafka changelog. This is a materialised view, rebuildable from CDC.
Do not make ClickHouse the inventory lock.
Merchant dashboards (sub-second)¶
Flink or Spark dumps aggregates (GMV per shop per minute) into ClickHouse. Raw orders stay in the lake + Postgres.
Recommendations¶
Nightly (then hourly) Spark builds co-purchase edges into Neo4j. The recs API hits Neo4j; it is not on the checkout path. Rebuild from Iceberg if the graph is wrong.
MATCH (u:User)-[:BOUGHT]->(p1:Product)
MATCH (u)-[:BOUGHT]->(p2:Product)
WHERE p1 <> p2
WITH p1, p2, count(u) AS co
WHERE co > 100
MERGE (p1)-[:FREQUENTLY_BOUGHT_WITH {count: co}]->(p2)
CDC handling (the actual engineering)¶
Debezium payload (simplified):
{
"before": {"order_id": "o001", "status": "PENDING", "total": 49.00},
"after": {"order_id": "o001", "status": "SHIPPED", "total": 49.00},
"op": "u",
"ts_ms": 1705312800000
}
Rules:
- Key Kafka records by primary key so a single-partition consumer can apply in order.
- Never
INSERTCDC updates into an append-only revenue table andSUM(total). - Use
op+ version:c/rupsert,uupsertafter,ddelete or tombstone. - Snapshot + streaming: the initial Debezium snapshot will re-emit the world; sinks must be idempotent.
- Outbox if you need "order created" business events with payload you control — CDC is a mirror of tables, not a public API.
def apply_cdc(event, dest):
op = event["op"]
if op in ("c", "r", "u"):
dest.merge(event["after"], key="order_id")
elif op == "d":
dest.delete(event["before"]["order_id"])
Hudi MoR / Iceberg MERGE INTO are the batch form of this.
GDPR and deletes¶
A user erasure is a pipeline, not a SQL statement on prod:
- OLTP delete/anonymise (source of truth).
- CDC delete events flow to the lake (Hudi/Iceberg row-level deletes).
- ClickHouse mutation or ReplacingMergeTree + delete row; compact.
- Search / recs graph / object-storage files with exports.
- Backups: retention policy, not instant erase. Document the legal delay.
If your lake is append-only Parquet without a table format, GDPR is a rewrite of every file that ever contained that user. That is why V1 already uses Iceberg or Hudi.
How it fails¶
| Failure | Symptom | Absorb with |
|---|---|---|
| Postgres disk on WAL | Primary down, not "data lag" | Fast sink, drop unused tables, monitor replication slot |
| Duplicate Spark run | Double GMV | Partition overwrite, MERGE, or transactional Iceberg commit |
Schema change on orders | Consumers crash or null columns | Registry compatibility; expand/contract |
Hot user_id in clicks | One Spark task / one Kafka partition | Salt bots; see Spark OOM incident |
| Iceberg planning slow | Trino/Spark takes minutes before reading | Expire snapshots, rewrite manifests; Iceberg incident |
| Recs graph drift | Weird recommendations | Rebuild from lake; graph is derived |
What still stays out at V2¶
- Training recs in Neo4j (export features, train elsewhere).
- Multi-region active-active Postgres unless product needs it (CDC gets much harder).
- Streaming every click through Flink into ClickHouse raw. Aggregate first.
- Using Kafka as the order system of record.
Evolution at 10×¶
| 10× of… | Change |
|---|---|
| Clickstream | More Kafka partitions, Iceberg compaction budget, maybe Flink pre-agg into ClickHouse. Still not Postgres. |
| Orders | Postgres primary vertical then Citus/shard by shop_id / customer_id. CDC per shard. Lake partitioned the same way. |
| Merchants querying live | ClickHouse tenant filter shop_id in ORDER BY. Row-level security. |
| GDPR volume | Incremental deletes + compaction SLAs; do not accumulate delete files forever. |
| Cross-shop analytics | Trino on Iceberg; keep OLTP out of those joins. |
At 10× you still should not put Neo4j on the 200 ms checkout path or replace Postgres with a lakehouse.
Decision table¶
| Decision | Choice | Why | Not yet / not this |
|---|---|---|---|
| System of record | Postgres | Transactions, checkout | ClickHouse, Kafka |
| Movement | Debezium → Kafka | Ordered per PK, replay | Polling dumps |
| Analytical SoR | Iceberg (or Hudi if upsert-heavy) | ACID, time travel, deletes | Raw S3 JSON |
| Dashboards <100 ms | ClickHouse aggregates | Scan cost | Trino on hot path |
| Ad-hoc | Trino | Federation | Copying to a fourth warehouse first |
| Recs | Batch graph derived from lake | Rebuildable | Online graph writes from checkout |
Apply this at work¶
- List entities and whether they append (clicks) or mutate (orders). Different sinks.
- Put CDC load on a staging diagram: which tables, estimated update rate, slot lag SLO.
- Write the revenue metric as a MERGE, not a SUM of CDC events.
- Name the GDPR path through every copy.
- Delay Flink and Neo4j until a product SLA names them.
Pair with Kafka labs (ordering per key) and the Spark skew incident (wholesale customer_id).
Entity map (what mutates)¶
| Entity | System of record | Stream | Lake pattern | Serving |
|---|---|---|---|---|
| Order header | Postgres | CDC, key order_id | MERGE / Hudi upsert | CH daily GMV (derived) |
| Order items | Postgres | CDC, key item_id or order_id | Nested or 1:N table | not on checkout |
| Payment | Postgres / PSP | CDC or PSP webhooks | Append + status merge | never CH as truth |
| User | Postgres | CDC; PII | masked curated | RLS |
| Inventory | Postgres | CDC sku | projection | Redis/API from projection or Postgres |
| Clickstream | App | Kafka only | append Iceberg by date | CH 7–30 d aggs |
| Co-purchase | Derived | batch | optional | Neo4j |
If two rows in this table share a store, good. If checkout shares a store with clickstream, bad.
Ordering guarantees you actually need¶
- All CDC events for
order_id=o001must be applied in order. Kafka partition key = PK. A consumer that shares the partition with other keys is fine; a random key is not. - Clickstream does not need total order across users. Per
session_idis enough if you sessionize. - Inventory decrements: if two Flink subtasks apply CDC out of order, stock goes negative. Key by SKU.
enable.idempotence=true on producers is not a substitute for keyed partitions.
Revenue metric (correct vs naive)¶
Naive: SUM(after.total) WHERE op='u' → every status change re-adds GMV.
Correct (current state):
-- Iceberg MERGE into dim/fact current
MERGE INTO fct_orders t
USING staging s
ON t.order_id = s.order_id
WHEN MATCHED AND s.ts_ms >= t.ts_ms THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *;
-- reporting
SELECT order_date, sum(total)
FROM fct_orders
WHERE status NOT IN ('CANCELLED', 'FAILED')
GROUP BY 1;
Or snapshot-style: emit a daily fact from Postgres (truth) and use the lake for clickstream + history of changes, not for GMV. Many finance teams prefer the replica dump for money and the lake for behaviour. That is a valid V1.
Debezium load sketch¶
Postgres logical decoding cost scales with WAL volume, not with "tables you care about" unless you filter.
- Full-row updates of a 50-column
userstable on everylast_login→ Kafka is full of login noise. REPLICA IDENTITY FULLon wide tables doubles payload (before+after).- Slot lag SLO: minutes, paged to DBA and data eng. If the sink dies, the primary dies next (disk).
V1: CDC only orders, payments, order_items. Users via nightly dump if they change slowly.
On-call 15 minutes¶
- Slot lag high? Sink or CH/Spark down — protect WAL disk first (pause noisy tables, never delete the slot without a rebuild plan).
- GMV 2× after a backfill? Duplicate MERGE or append-only fact — quality recon.
- Trino "today's clicks" scans 90 days? Missing
date=predicate / wrong partition column. - Inventory API stale? Flink lag on
skuhot partition (flash sale SKU) — same as Kafka incident 1.
GDPR drill (tabletop)¶
Pick user U. List every copy: PG, Kafka (retention days), Iceberg files, CH, Neo4j, S3 exports, notebooks. Time-box a staging delete. If Kafka retention is 14 days, erasure is not instant there — document it. If Iceberg expire is 30 days, time travel still sees U — security.