Pipeline Anatomy: Intermediate
At a mid-size retailer the order pipeline begins as 1 chain. A Kafka topic orders.v1 feeds an hourly Spark job that writes Parquet to S3, and a Snowflake task loads fct_revenue_hourly for the finance dashboard. The first change request comes from marketing, which wants orders joined to ad spend, and the quickest path is a second job reading the same topic. Three months later the recommendations team builds a third. Each job drops test orders, casts amount from string to NUMERIC and deduplicates on order_id, and each does it a little differently.
The cost arrives as disagreement. For Tuesday hour 14 finance counts 40,000 orders, marketing counts 40,210 because its job never filtered the staff-discount test accounts, and the recommendations model trains on 39,600 because its deduplication keys on order_id plus updated_at and drops legitimate resubmissions. Three teams hold 3 numbers, and a meeting decides which one is real. New sources multiply the problem: once the Postgres product catalog, the Zendesk ticket API and the Google Ads spend export arrive as sources, 4 sources and 3 consumers wired point to point need 12 jobs, and the cleaning of each source is written in 3 of them.
The trigger is the second consumer
One source and 1 consumer need no middle layer; the transform can shape data for its only reader, which is the beginner pipeline. The second consumer of the same source is the moment to split, because the cleaning either gets copied or gets shared, and copied cleaning drifts within weeks. The second source feeding the same consumer is the other trigger: conforming keys, such as the catalog's sku against the order line's product_code, is work every later consumer needs too. Either event turns N x M jobs into N + M, with 1 landing job per source and 1 serving job per consumer.
What each layer promises
| Layer | Holds | Promise to the next layer | Owner |
|---|---|---|---|
| Raw (bronze) | Every record as the source sent it, Parquet on S3 partitioned by arrival hour, never updated | Nothing lost, nothing changed, replayable from any hour | Data platform |
| Curated (silver) | 1 row per order, typed, deduplicated, test accounts removed, catalog keys conformed | The company's single definition of an order | Data platform with domain owners |
| Serving (gold) | Aggregates and joins shaped for 1 consumer, derived only from curated | Fast for its reader, rebuildable from curated at any time | The consuming team |
Three sources land hourly into raw, 1 dbt model builds curated orders once an hour, and each consumer owns 1 serving job that reads curated. Adding a fourth consumer adds 1 node on the right and nothing on the left.
The recommendations team needs order data for a model. Today 1 Spark job reads orders.v1, filters test accounts, deduplicates and writes fct_revenue_hourly, an hourly aggregate finance reads. What should the team read?
Where the transform runs
- A separate engine owns the compute; the warehouse holds only finished tables
- Raw data is gone after load; a new question needs a new extract
- Masking, parsing and model inference run in general-purpose code
- Fits data that must be reduced or protected before it lands
- Warehouse compute does the work, sized per job
- Raw tables persist; a new model is a new SQL file over old rows
- Transforms are SQL, tested and versioned with dbt
- Fits any transform SQL can express over data allowed to land
Choose per transform
The retailer's curated layer holds both. Casting amount to NUMERIC, deduplicating on order_id and removing test accounts are ELT: dbt models over raw tables that keep every row, so a rule change reruns against history without touching the source. The model below does the cast, filter and deduplication for curated orders, and it runs in Snowflake in about 40 seconds over 1 hour of raw data.
- ▸Protection: card numbers and emails in order events are hashed by the ingestion job, so no warehouse table ever holds them and Time Travel cannot bring them back.
- ▸Expression: extracting features from product images or scoring a model is Python, so it runs in Spark before load and only its outputs land.
- ▸Volume: raw clickstream at 3 million events an hour is 72 million rows a day; sessionizing before load cuts warehouse storage and every downstream scan by roughly 50x.
An ingestion job pulls support tickets from the Zendesk API into Snowflake. Ticket bodies contain customer emails and, a few times a week, card numbers typed into free text. Analysts need the ticket text searchable. Where does the masking run?
In the curated layer, orders_enriched joins 3 inputs: curated orders (hourly, from Kafka), the product catalog (Debezium change capture out of Postgres, landing every 15 minutes) and FX rates (1 daily file from the European Central Bank, published around 16:00 CET). A step with 3 inputs faces 3 questions a single-input step never does: when it may run, which version of each input it reads, and what it does when 1 input has not arrived.
Cadence collapses to the slowest input
The join can run for hour 14 only when every input it needs for hour 14 exists. Orders land by 14:05 and catalog changes by 14:15, so the join could start at 14:15. The FX rate arrives once a day, so an hourly join at 03:00 UTC either waits 12 hours for today's rate or reads yesterday's. The orchestrator makes the wait explicit: in Airflow, an ExternalTaskSensor per upstream DAG or a Dataset schedule on all 3; in Dagster, 3 declared upstream assets. Either way, the step's effective cadence is the slowest of its inputs unless the design names which inputs may be stale and by how much.
| Input | Cadence | Typically lands | Staleness the join accepts |
|---|---|---|---|
| Curated orders | hourly | :05 past the hour | None, the run waits |
| Product catalog (SCD2) | every 15 minutes | :15 past the hour | Up to 1 hour, prices change rarely |
| FX rates | daily | 15:00 UTC | Previous day's rate until today's lands, rate date recorded |
Three inputs at 3 cadences converge on 1 transform. The hourly edge gates the run, the 15-minute edge may be up to 1 hour stale, and the daily edge supplies yesterday's value until today's arrives.
Which version of each input
A join that reads the current catalog gives a different answer on a rerun than it gave the first time. A backfill of March 12 run on March 20 would price March 12 orders at March 20 prices and file them under categories renamed in between. A merge step needs an as-of rule per input, each tied to the run's data_interval_start: orders whose order_hour equals the interval, catalog rows whose valid_from and valid_to bracket the interval's start, and the FX rate for the interval's date. With all 3 rules the step is reproducible, and reproducibility is what makes its retries and backfills safe.
What a missing input does to the output
An inner join on the catalog drops every order whose sku has not arrived, and the drop is silent: 40,000 orders in, 39,850 out, and the dashboard shows $3,600 less revenue than the payment processor settled. A left join keeps the 150 orders and leaves category null, which is visible and recoverable, because a trailing-window rebuild fills them in when the catalog catches up. The characteristic failure of a merge step is partial input dressed as complete input, so the step checks before it writes. A row-count assertion that orders_enriched for the hour equals curated orders for the hour guards against the inner-join drop. A null-rate check on category above 0.1% guards against the late catalog, whose 150 nulls are 0.375% of the hour.
The orders_enriched join for hour 03 UTC cannot find an FX rate for today, because the ECB publishes around 15:00 UTC. The finance dashboard needs hourly revenue in USD through the day. Which design serves it?
Kafka to raw is hourly Parquet; Postgres to raw is a change stream flushed every 15 minutes; raw to curated is hourly and gated on both; the gate passes an hour to both consumers or to neither.
Ends first, then the fan-in
Start at the 2 ends, because they set the requirements. Sources decide arrival behaviour: Kafka delivers within seconds and replays from any offset inside its 7 days of retention, while the Debezium connector follows the Postgres write-ahead log and fails its task when it loses its replication slot. Consumers decide freshness: the finance mart promises yesterday complete by 08:00, the feature store promises 15-minute freshness. Then find where edges converge. Here orders meet the catalog at curated_orders, the role orders_enriched played in section 2: its cadence is bounded by its slowest input, and every consumer inherits that bound.
Cadence per edge
Failure behaviour per node
For each node, 3 questions. Does it retry, and is its write safe to retry? The raw landing overwrites an hourly S3 prefix, so 3 retries leave 1 copy. The curated load deletes and inserts by order_hour in 1 transaction, so a retry after a timed-out commit removes its own earlier rows before loading. A load that appended instead would commit 40,000 rows, lose the acknowledgement, and commit 40,000 more on attempt 2, with every task green and finance seeing double revenue at 08:00. Does a failure block downstream or let stale data through? The gate blocks, so a bad hour never reaches the mart, at the price of a frozen dashboard. Who is paged? The monitorAlert on each node names the condition, and the owner column names the person.
| Node | Cadence | On failure | Owner |
|---|---|---|---|
| kafka_orders | continuous | lag alert at 5 min, replay within 7 days | Checkout team |
| pg_catalog | change stream, flushed every 15 min | connector-state alert, no automatic recovery after slot loss | Catalog team |
| s3_raw | hourly | 3 retries 5 min apart, overwrite prefix | Data platform |
| curated_orders | hourly | 2 retries, delete and insert, pages platform on-call | Data platform |
| dq_gate | hourly | blocks both consumers, pages platform on-call | Data platform |
| finance_mart | hourly | 2 retries, dashboard shows stale hours | Finance analytics |
| feast_features | 15 min | 1 retry, model serves last good values | Recommendations |
Ownership at the layer boundary
Ownership changes hands where the layer changes. The checkout team owns the topic and its schema, the catalog team owns the Postgres tables, the data platform owns raw through the gate, and each consuming team owns its serving table. The boundary between layers is where the contract lives: the checkout team may add fields to orders.v1 but may not rename order_id, and the platform may change how curated_orders is built but not what a row means. A diagram without owners turns every cross-boundary incident into 20 minutes of finding out who to call.
- ▸Which nodes are sources and consumers, and what freshness does each consumer need?
- ▸Where do edges converge, and which input bounds that node's cadence?
- ▸What moves along each edge, and how often?
- ▸For each node: does it retry safely, does it block or pass stale data, who is paged?
- ▸Where does ownership change, and what is the contract at that boundary?
At 02:00 the Debezium connector for the catalog loses its replication slot and its task fails. Reading the diagram above, what does the finance mart show at 09:00, and who was paged?
Fork at ingestion
The first design gives each consumer its own reader on orders.v1. The dashboard path is the hourly batch already built. The feature path is a 15-minute Spark Structured Streaming job that reads the topic, cleans, aggregates per customer and writes to Feast's offline store, with its own test-account filter, deduplication and cast of amount. It ships fast, because neither team waits on the other, and it recreates the drift section 0 opened with.
Fork at the curated layer
The second design keeps 1 raw landing and 1 curated orders table, and forks after it: 1 serving job aggregates curated orders into the finance mart, and 1 computes features from the same rows. The cost is that the curated layer must run at the faster consumer's cadence. An hourly curated table cannot feed a 15-minute feature refresh, so the curated load moves to a 15-minute micro-batch, and the finance mart, still hourly, reads 4 curated slices per run. A 15-minute MERGE over 10,000 orders completes in under 30 seconds on an X-Small Snowflake warehouse.
The dashboard and the feature store fork after curated, which now runs every 15 minutes. Fraud scoring forks at the topic because a 200-millisecond budget allows nothing else, and a nightly job reconciles its decisions against curated orders.
| Concern | Fork at ingestion | Fork at curated |
|---|---|---|
| Definition of an order | 2 implementations that drift | 1 dbt model, both read it |
| Fix to the test-account filter | 2 pull requests, 2 deploys | 1 change, both consumers next run |
| Freshness | each path at its own cadence | curated at the fastest consumer's cadence |
| Reconciliation | numbers disagree, a meeting picks one | both derive from the same rows |
Point-in-time correctness decides it. A training set asks, for each order on March 12, what the customer's 7-day order count was at that moment. From curated orders with order_hour as event time, that is a window over 1 table. From a streaming path with its own deduplication, the answer depends on which late orders the stream happened to see, and the model trains on features the dashboard cannot reproduce. Forking at curated makes the model's orders match finance's by construction.
When the fork belongs at ingestion
A consumer that needs latency the curated layer cannot provide reads the source directly. Fraud scoring at checkout needs a decision within 200 milliseconds of the order event, and a 15-minute micro-batch is 4,500 times too slow. It gets a Flink job on orders.v1 with minimal cleaning, plus a nightly job reconciling its decisions against curated orders. The early fork is deliberate and bounded, and its reconciliation cost is written into the design.
A fourth consumer appears: fraud scoring that must accept or hold an order within 200 milliseconds of the event. The curated layer runs every 15 minutes. Where does this consumer attach?
- Split a pipeline into layers at the second consumer or the second source, before cleaning gets copied
- Write each layer's promise down: raw is complete and untouched, curated is the one definition
- Pin every input of a merge step to the run's interval with an as-of rule
- Run the curated layer at the fastest consumer's cadence and let slower consumers aggregate
- Put a count and freshness check between curated and serving, and make it block
- Don't let a consumer read another consumer's serving table as if it were curated
- Don't inner join a late-arriving dimension; keep the rows and make the nulls visible
- Don't mask PII in a model after the raw rows have landed in the warehouse
- Don't fork at ingestion for a consumer a 15-minute curated layer would satisfy
> A grocery delivery company, 2 years after launch, has 6 jobs reading its order topic: finance, marketing, a demand forecast, a courier-dispatch feature store, a fraud model and a customer support view. Finance and marketing report order counts 3% apart every week, the forecast team spent a quarter rebuilding deduplication the finance team had already written, and the dispatch features are refreshed every 5 minutes from a topic that the fraud model also reads with its own filters. The platform team is asked to redraw it.
How a pipeline grows from one chain into layers, forks and joins, and how to read what the grown-up diagram says.
- Category
- Pipeline Architecture
- Difficulty
- intermediate
- Duration
- 20 minutes
- Challenges
- 11 hands-on challenges
Topics covered: Many Sources, One Curated Layer, ETL vs ELT, Steps That Merge Many Inputs, Reading a Real Pipeline Diagram, One Source, Two Consumers
Lesson Sections
- Many Sources, One Curated Layer (concepts: Medallion Architecture)
The trigger is the second consumer What each layer promises The medallion names, bronze, silver and gold, already describe the 3 layers. Designing them means writing down what each one promises to the layer beside it, because that promise lets finance and recommendations stop reading the Kafka topic themselves. The layer is paid for in latency and storage. Each extra hop runs on its own schedule, so an hourly curated layer bounds every serving table at hourly freshness or slower. Storage roughly
- ETL vs ELT (concepts: ELT vs ETL)
Where the transform runs ETL and ELT differ in where the transform runs, and that single fact decides what the loaded data looks like. In ETL, an engine between source and warehouse (Informatica PowerCenter, SSIS, a Spark cluster) reads the source, cleans and reshapes rows in its own memory, and loads only finished tables. The warehouse never holds raw data. In ELT, the loader copies source data into the warehouse as close to verbatim as the types allow, and the transform runs inside the warehou
- Steps That Merge Many Inputs (concepts: Dependency Management, DAG Orchestration)
Cadence collapses to the slowest input Which version of each input What a missing input does to the output The step also changes what an upstream rerun means. When the catalog CDC job is backfilled for a day, every join run that read that day must rerun, or the enriched table keeps pre-fix prices. Dagster's partitioned assets, with partition mappings, propagate that backfill to the dependent join partitions. Airflow Datasets record the dependency, but a Dataset update triggers 1 new join run at
- Reading a Real Pipeline Diagram (concepts: Pipeline Monitoring)
The diagram below is the retailer's order pipeline as it runs today, with the operating facts a production diagram carries and a sketch leaves off. Reading it is a procedure, and applied to a 40-node diagram at a new job it tells you in 20 minutes what a month of incidents would otherwise teach. Ends first, then the fan-in Cadence per edge An edge carries no words, so each one gets 2 questions: what moves along it and how often. Curated to the mart is hourly aggregation; curated to the feature s
- One Source, Two Consumers (concepts: Medallion Architecture, Batch vs Streaming)
Two teams read the retailer's orders. The finance dashboard wants hourly revenue by category, complete for yesterday by 08:00 and refreshed through the day. The recommendations team wants per-customer features in Feast: orders in the last 7 days, average basket, most recent category, refreshed every 15 minutes, and computed point-in-time so that a training set built for March 12 sees only what was known on March 12. Same source, 2 consumers, and 2 places the pipeline can fork. Fork at ingestion