This page requires JavaScript to display.
P
Unpacking...
← Data Engineering
Data Engineering

Data Architecture Patterns

Lambda and Kappa in depth with worked examples, a decision guide, and the patterns around them: medallion, lakehouse, data mesh, CDC and outbox, ETL vs ELT, streaming reliability, serving, and reference architectures.

Contents
1. How to Choose an Architecture 2. Lambda Architecture 3. Kappa Architecture 4. Lambda vs Kappa: Side by Side 5. Medallion (Bronze / Silver / Gold) 6. Warehouse, Lake, Lakehouse 7. Data Mesh & Data Fabric 8. Change Data Capture & the Outbox Pattern 9. ETL, ELT & Reverse ETL 10. Streaming Reliability Patterns 11. Serving Patterns 12. Reference Architectures 13. Common Interview Questions

1. How to Choose an Architecture

A data architecture pattern is a proven way to arrange ingestion, storage, processing and serving. No pattern is best in general; you choose by answering a few questions about the workload:

QuestionWhy it matters
How fresh must results be? (ms, seconds, minutes, daily)Drives batch vs streaming, and whether you need a separate fast path
How correct must they be, and can they be corrected later?Late or revised data (settlements, chargebacks) favours a recomputable source of truth
Are sources events, database changes, or files?Events suit streaming; files and nightly extracts suit batch; databases suit CDC
How much history must be reprocessed when logic changes?Determines whether you need a replayable log or recomputation from storage
How many teams own the data?One central team suits a central platform; many domains may suit a data mesh
What skills and budget do you have?Two engines (Lambda) cost more to build and run than one

Interview tip: state the requirements first ("sub-second alerts, daily regulatory reports, 2 TB/day, late settlement files"), then pick the pattern and explain the trade-off you are accepting.

2. Lambda Architecture

Lambda (Nathan Marz, around 2011) runs two pipelines over the same immutable data: a batch layer that periodically recomputes complete, accurate results from all history, and a speed layer that processes recent events immediately to fill the gap until the next batch run. A serving layer merges both views for queries.

                       ┌──────────────────── BATCH LAYER ─────────────────────┐
                       │ master dataset (immutable, append-only: S3 / Delta)    │
 sources ──► Kafka ──┬─►│ → nightly recompute from ALL history (Spark)          │──► batch views ──┐
 (events,     (log)  │  └────────────────────────────────────────────────────────┘   (complete,     │
  files)             │                                                                 correct)      ├──► SERVING
                     │  ┌──────────────────── SPEED LAYER ─────────────────────┐                   │    LAYER
                     └─►│ stream processing (Flink / Spark Structured Streaming)│──► real-time ─────┘  (merge views,
                        │ only data since the last batch run, low latency        │    views              answer queries)
                        └────────────────────────────────────────────────────────┘    (approximate,
                                                                                         recent only)
LayerJobTypical tech
BatchRecompute views from the full master dataset; fixes late data, bugs and logic changes by recomputationSpark / Databricks jobs over S3, ADLS or Delta; Hive in older stacks
SpeedIncremental, low-latency views of data the batch layer has not processed yetFlink, Spark Structured Streaming, Kafka Streams
ServingExpose batch views and real-time views together for queriesDelta / warehouse tables with a merging view, Cassandra, Druid, Redis

Worked example: daily transaction totals per account

Requirement: dashboards need today's running totals within seconds, but finance needs exact daily totals, including late settlement corrections. The speed layer keeps today's totals fresh:

from pyspark.sql import functions as F, types as T
from delta.tables import DeltaTable

schema = T.StructType([
    T.StructField("txn_id", T.StringType()),
    T.StructField("account_id", T.StringType()),
    T.StructField("amount", T.DecimalType(18, 2)),
    T.StructField("event_ts", T.TimestampType()),
])

events = (spark.readStream.format("kafka")
    .option("kafka.bootstrap.servers", "kafka:9092")
    .option("subscribe", "txn.events")
    .option("startingOffsets", "latest")
    .load()
    .select(F.from_json(F.col("value").cast("string"), schema).alias("t"))
    .select("t.*"))

running = (events
    .withWatermark("event_ts", "10 minutes")
    .groupBy("account_id", F.to_date("event_ts").alias("txn_date"))
    .agg(F.sum("amount").alias("total_amount"), F.count("*").alias("txn_count")))

def upsert_speed(batch_df, batch_id):  # MERGE so totals update in place
    (DeltaTable.forName(spark, "speed.daily_txn_totals").alias("t")
        .merge(batch_df.alias("s"), "t.account_id = s.account_id AND t.txn_date = s.txn_date")
        .whenMatchedUpdateAll().whenNotMatchedInsertAll().execute())

(running.writeStream
    .outputMode("update")
    .foreachBatch(upsert_speed)
    .option("checkpointLocation", "/chk/speed_daily_txn_totals")
    .trigger(processingTime="30 seconds")
    .start())

The batch layer recomputes exact totals for completed days from the master dataset every night, including late and corrected records:

exact = (spark.read.table("bronze.transactions")  # immutable master dataset
    .dropDuplicates(["txn_id"])  # batch can afford a global dedupe
    .join(spark.read.table("bronze.settlement_adjustments"), "txn_id", "left")
    .withColumn("final_amount", F.coalesce("adjusted_amount", "amount"))
    .where(F.col("txn_date") < F.current_date())  # complete days only
    .groupBy("account_id", "txn_date")
    .agg(F.sum("final_amount").alias("total_amount"), F.count("*").alias("txn_count")))

exact.write.mode("overwrite").saveAsTable("batch.daily_txn_totals")  # recompute, don't patch

The serving layer answers queries from the batch view where it exists and from the speed view only for days the batch has not covered yet:

CREATE OR REPLACE VIEW serving.daily_txn_totals AS
SELECT account_id, txn_date, total_amount, txn_count, 'batch' AS source
FROM batch.daily_txn_totals
UNION ALL
SELECT account_id, txn_date, total_amount, txn_count, 'speed' AS source
FROM speed.daily_txn_totals
WHERE txn_date > (SELECT MAX(txn_date) FROM batch.daily_txn_totals);   -- only uncovered days
-- daily reconciliation: how far did the fast path drift from the exact numbers?
SELECT b.txn_date,
       SUM(b.total_amount)                         AS batch_total,
       SUM(s.total_amount)                         AS speed_total,
       SUM(s.total_amount) - SUM(b.total_amount)   AS drift
FROM batch.daily_txn_totals b
JOIN speed.daily_txn_totals s USING (account_id, txn_date)
WHERE b.txn_date = current_date() - INTERVAL 1 DAY
GROUP BY b.txn_date;
StrengthsWeaknesses
Batch recomputation from immutable data fixes any error: bugs, late data, logic changesThe same business logic is written and maintained twice, often in two languages or engines
Low-latency results without giving up accuracyThe two paths can disagree, so you need reconciliation and a rule for which wins
Each layer can use the best tool for its jobTwo systems to build, monitor, secure and pay for

Reduce duplicated logic by sharing it: keep rules in SQL or a shared Python library used by both layers, or let one engine (e.g. Spark) run both paths. Banking fraud platforms often keep Lambda deliberately: Flink for millisecond decisions plus a nightly lakehouse recomputation for regulatory accuracy (see the Kafka & Kinesis guide for that setup).

3. Kappa Architecture

Kappa (Jay Kreps, 2014) removes the batch layer: everything is a stream. All data goes into a durable, replayable log, one stream-processing codebase produces the serving tables, and reprocessing means replaying the log through a new version of the job.

 sources ──► Kafka / Kinesis / Delta bronze (replayable log, long retention) ──► stream processor v1 ──► serving tables v1
                                         │                                                               ▲ readers
                                         └── replay from offset 0 ──► stream processor v2 ──► serving tables v2
                                                                       (new logic)            switch readers when
                                                                                              v2 has caught up

Worked example: one job, versioned outputs

from pyspark.sql import functions as F

def build_totals(version: str, starting_offsets: str):
    events = (spark.readStream.format("kafka")
        .option("kafka.bootstrap.servers", "kafka:9092")
        .option("subscribe", "txn.events")
        .option("startingOffsets", starting_offsets)
        .load()
        .select(F.from_json(F.col("value").cast("string"), schema).alias("t"))
        .select("t.*")
        .dropDuplicates(["txn_id"]))  # stream dedupe (bounded by the watermark)

    totals = (events
        .withWatermark("event_ts", "2 hours")
        .groupBy(F.window("event_ts", "1 day").alias("w"), "account_id")
        .agg(F.sum("amount").alias("total_amount")))

    return (totals.writeStream
        .outputMode("append")  # windows emitted once final
        .option("checkpointLocation", f"/chk/txn_totals_{version}")
        .toTable(f"gold.txn_totals_{version}"))

v1 = build_totals("v1", "latest")       # running in production

# Logic changed (e.g. new rounding rule)? Deploy v2 alongside v1 and replay the whole log:
v2 = build_totals("v2", "earliest")
-- once v2 has caught up to the head of the log and reconciles with v1:
CREATE OR REPLACE VIEW gold.txn_totals AS SELECT * FROM gold.txn_totals_v2;   -- switch readers
-- then stop v1 and drop gold.txn_totals_v1

Kappa on a lakehouse

Keeping months of data in Kafka is expensive. A common modern variant uses an append-only Delta or Iceberg bronze table as the replayable log: Kafka feeds bronze, and downstream streams read the table, so replay is just a new stream from the beginning of bronze.

(spark.readStream.table("bronze.transactions")  # the table IS the replayable log
    .withWatermark("event_ts", "2 hours")
    .groupBy(F.window("event_ts", "1 day"), "account_id")
    .agg(F.sum("amount").alias("total_amount"))
    .writeStream.outputMode("append")
    .option("checkpointLocation", "/chk/gold_totals_v3")
    .trigger(availableNow=True)  # same code runs incrementally OR as a "batch"
    .toTable("gold.txn_totals_v3"))
RequirementWhy Kappa needs it
Replayable source with enough retentionKafka retention or tiered storage, or a bronze table holding full history
Event-time processingReplays must produce the same results as live processing, so logic uses event time and watermarks, never arrival time or now()
Deterministic, idempotent logic and sinksA replay must not double-count; write to new versioned tables or MERGE on keys
Capacity to replay quicklyReprocessing months of data needs a lot of parallelism (partitions, cluster size) for a while
StrengthsWeaknesses
One codebase and one engine: no duplicated logic, no divergenceReprocessing large histories through a stream can be slow and costly
Simpler operations and a single mental modelNeeds long retention or a log-structured lake
Naturally low latencySome problems (large historical joins, global recomputation, ML training) are easier in batch

4. Lambda vs Kappa: Side by Side

LambdaKappa
PipelinesTwo: batch + speedOne: stream
Business logicImplemented twiceImplemented once
Source of truthImmutable master dataset, recomputed by batchThe replayable log (or bronze table)
Correcting historyRerun the batch layerReplay the log through a new job version
LatencyReal-time via speed layer; exact after batchReal-time throughout
ConsistencyTwo answers can differ; reconcileOne answer
File / batch sourcesNatural in the batch layerMust be turned into streams (Auto Loader, file connectors)
Operational costTwo engines and skill setsOne engine, but must handle replays
Typical stacksFlink or Spark Streaming + Spark batchKafka + Flink; Kafka/Delta + Spark Structured Streaming

Decision guide

SituationChoose
Millisecond decisions AND exact, auditable daily numbers with late corrections (fraud + regulatory reporting)Lambda, with shared logic and reconciliation
Mostly event data, one team, seconds of latency acceptable, consistency mattersKappa
Lakehouse platform (Databricks, Iceberg) with streaming tablesKappa-style: one incremental pipeline over bronze, run continuously or as availableNow batches
Mostly daily file drops and database extracts, no real-time needNeither: plain batch (ELT + medallion)
Real-time needed only for a few metricsBatch platform plus a small streaming side-path, not full Lambda

Modern lakehouses blur the line: Delta and Iceberg tables can be read as batch or as stream, so the same code can serve both latency needs. Many teams today run "Kappa on the lakehouse" and add a separate Flink path only where milliseconds matter.

5. Medallion (Bronze / Silver / Gold)

The medallion pattern organises a lakehouse into layers of increasing quality. It is orthogonal to Lambda/Kappa: either can feed and maintain medallion layers.

LayerContentsRules
BronzeRaw data as received, plus ingestion metadata (source file, offset, load time)Append-only, never edited; the replay point
SilverCleaned, deduplicated, conformed and validated entities; PII tokenisedMERGE / SCD2; data-quality checks; quarantine bad rows
GoldBusiness-level aggregates, star schemas, featuresShaped for specific consumers (BI, ML, APIs)
# bronze → silver: dedupe, type, validate, upsert
silver = (spark.readStream.table("bronze.orders")
    .withColumn("amount", F.col("amount").cast("decimal(18,2)"))
    .filter(F.col("order_id").isNotNull())
    .dropDuplicatesWithinWatermark(["order_id"])  # Spark 3.5+
    .withWatermark("updated_at", "1 hour"))

# silver → gold: business aggregate for dashboards
gold = (spark.read.table("silver.orders")
    .groupBy("order_date", "region")
    .agg(F.sum("amount").alias("revenue"), F.countDistinct("customer_id").alias("customers")))

See the Data Lake and Spark & Databricks guides for full medallion pipelines with Auto Loader, MERGE and SCD2.

6. Warehouse, Lake, Lakehouse

Data warehouseData lakeLakehouse
StorageProprietary, managed by the warehouseOpen files on object storageOpen files + open table format (Delta, Iceberg, Hudi)
SchemaOn writeOn readOn write, with evolution
Transactions / updatesYesNo (files only)Yes: ACID, MERGE, time travel
WorkloadsSQL / BIData science, raw storage, MLBI, SQL, streaming, ML on one copy of data
ExamplesRedshift, Synapse dedicated, Snowflake, BigQueryS3 / ADLS with Glue or HiveDatabricks, Iceberg on S3 with Athena/Trino, Fabric OneLake

The lakehouse arose to stop running a lake for ML and a separate warehouse for BI with copies and pipelines between them. Many organisations still combine a lakehouse with a warehouse for specific BI needs.

7. Data Mesh & Data Fabric

Data mesh (Zhamak Dehghani, 2019) is an organisational pattern for large companies where one central data team has become a bottleneck. It rests on four principles:

PrincipleMeaning
Domain ownershipTeams that know the data (payments, lending, cards) own their analytical data end to end
Data as a productEach dataset is a product with an owner, documentation, SLAs, quality checks and a stable contract
Self-serve data platformA central platform team provides storage, pipelines, catalog and security as easy-to-use services
Federated computational governanceA small set of global rules (classification, privacy, interoperability) enforced automatically by the platform
# a data product contract (often kept in Git next to the pipeline code)
dataProduct: payments.daily_settlements
owner: payments-data-team@acme.com
description: One row per merchant per business date with settled amounts.
output:
  table: payments_prod.gold.daily_settlements
  format: delta
schema:
  - { name: merchant_id,   type: string,        nullable: false }
  - { name: business_date, type: date,          nullable: false }
  - { name: settled_amount, type: decimal(18,2), nullable: false }
quality:
  - freshness: available by 06:00 IST on business days
  - unique: [merchant_id, business_date]
  - row_count_change_vs_previous_day: "< 30%"
classification: confidential
access: request via data catalog; approval by product owner

Data fabric is a technology approach: an integration layer driven by active metadata (catalog, lineage, semantics) that connects data across many systems, often virtualising access rather than copying data. Mesh changes who owns data; fabric changes how it is connected. They can coexist.

Use data mesh whenAvoid it when
Many domains with capable engineering teamsA small company or a single data team
The central team is a bottleneckDomains lack data engineering skills
A strong self-serve platform exists or can be builtGovernance is immature; mesh then amplifies inconsistency

8. Change Data Capture & the Outbox Pattern

CDC approachHowTrade-offs
Query-basedPoll with WHERE updated_at > :watermarkSimple; misses hard deletes and intermediate updates; loads the source
Log-basedRead the database transaction log (Debezium, AWS DMS, GoldenGate)Captures every insert, update and delete in order with little source impact; more moving parts
Trigger-basedDatabase triggers write changes to an audit tableWorks without log access; adds write overhead to the source
{
  "name": "orders-cdc",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "plugin.name": "pgoutput",
    "database.hostname": "orders-db.internal",
    "database.port": "5432",
    "database.user": "cdc_reader",
    "database.password": "${file:/opt/secrets/orders-db.properties:password}",
    "database.dbname": "orders",
    "topic.prefix": "orders",
    "table.include.list": "public.orders,public.order_items"
  }
}

Each change becomes a Kafka event with before, after and op (c = create, u = update, d = delete, r = snapshot read). Apply them to the lake with MERGE, keeping the latest change per key:

from pyspark.sql import Window

def apply_cdc(batch_df, batch_id):
    latest = (batch_df
        .withColumn("rn", F.row_number().over(
            Window.partitionBy("order_id").orderBy(F.col("source_lsn").desc())))
        .filter("rn = 1").drop("rn"))
    (DeltaTable.forName(spark, "silver.orders").alias("t")
        .merge(latest.alias("s"), "t.order_id = s.order_id")
        .whenMatchedDelete(condition="s.op = 'd'")
        .whenMatchedUpdateAll(condition="s.op IN ('u', 'c', 'r')")
        .whenNotMatchedInsertAll(condition="s.op <> 'd'")
        .execute())

The transactional outbox pattern

A service must update its database and publish an event. Doing both separately risks one succeeding and the other failing ("dual write"). Instead, write the event to an outbox table in the same database transaction, and let CDC publish it:

BEGIN;
  UPDATE accounts SET balance = balance - 500 WHERE account_id = 'ACC1';
  INSERT INTO outbox (event_id, aggregate_id, event_type, payload, created_at)
  VALUES (gen_random_uuid(), 'ACC1', 'FundsDebited', '{"amount": 500}', now());
COMMIT;
-- Debezium reads the outbox rows from the log and publishes them to Kafka:
-- the event is published if and only if the transaction committed.

9. ETL, ELT & Reverse ETL

PatternFlowWhen
ETLExtract → transform on a separate engine → load the finished resultSensitive data must be cleaned before landing, or the target is expensive or limited
ELTExtract → load raw into the lake or warehouse → transform there (dbt, Spark SQL)Default on modern cloud platforms: keeps raw history, uses scalable in-platform compute
Reverse ETLWarehouse or lakehouse → operational tools (CRM, marketing, support)Push modelled data (scores, segments) back to business applications
Zero-ETL / federationQuery or replicate sources without building pipelines (e.g. Aurora → Redshift zero-ETL, Fabric mirroring)Simple replication needs; transformations still happen downstream

10. Streaming Reliability Patterns

PatternProblem it solvesHow
Idempotent sinkRetries and replays create duplicatesMERGE / upsert on a key, or overwrite a partition, instead of blind appends
Exactly-once via checkpointsLosing or repeating data after failuresCheckpoint source offsets and state together; use transactional or idempotent sinks
Dead-letter queue (DLQ)One bad record blocks or crashes the pipelineRoute unparseable or invalid records to a quarantine table or topic with the error, and alert
WatermarksUnbounded state and undefined "completeness"Declare allowed lateness; handle later data with side outputs or batch correction
Stream–static enrichmentEvents need reference data (customer tier, merchant)Join the stream with a slowly changing table (broadcast or lookup), refreshed periodically
Backpressure and rate limitsBursts overwhelm downstream systemsBounded batch sizes (maxOffsetsPerTrigger), autoscaling, buffering in the log
parsed = raw.select(
    F.col("value").cast("string").alias("raw_value"),
    F.from_json(F.col("value").cast("string"), schema).alias("t"))

good = parsed.filter("t.txn_id IS NOT NULL AND t.amount IS NOT NULL").select("t.*")
bad = (parsed.filter("t.txn_id IS NULL OR t.amount IS NULL")
          .withColumn("error", F.lit("missing txn_id or amount"))
          .withColumn("quarantined_at", F.current_timestamp()))

good.writeStream.option("checkpointLocation", "/chk/silver_txn").toTable("silver.transactions")
bad.writeStream.option("checkpointLocation", "/chk/dlq_txn").toTable("quarantine.transactions")  # DLQ

11. Serving Patterns

PatternIdeaExample
Materialised views / aggregatesPrecompute expensive queries for fast readsGold daily totals, Databricks materialized views, Redshift MVs
CQRS (command–query separation)Write model and read model are separate, kept in sync by eventsWrites to Postgres; events build a denormalised read model in Elasticsearch or Cosmos DB
Low-latency key-value servingMillisecond lookups by key for applicationsLatest risk score per account in Redis, DynamoDB, Cosmos DB or MongoDB
Semantic layer / metrics storeDefine metrics once and serve consistent numbers to every BI tooldbt semantic layer, Databricks metric views, Power BI datasets
Feature storeSame feature definitions for ML training (offline) and inference (online)Databricks Feature Engineering, SageMaker Feature Store, Feast

12. Reference Architectures

Real-time fraud detection (Lambda, bank)

 cards / UPI / ATM ──► Kafka (txn events, keyed by account)
                         ├──► Flink: per-account velocity + rules + model (ms) ──► decision API, alerts topic, Redis
                         └──► Delta bronze ──► nightly Databricks: silver (dedupe, settlements) ──► gold (exact totals,
                                                regulatory reports, model training) ──► reconciled against Flink outputs

E-commerce analytics (Kappa on a lakehouse)

 clickstream, orders (CDC via Debezium) ──► Kafka ──► Delta bronze (append-only, full history)
   ──► Spark Structured Streaming: silver sessions, orders (MERGE) ──► gold funnels, revenue (streaming tables)
   ──► SQL warehouse dashboards (refresh every minute) · replay bronze into _v2 tables when logic changes

Enterprise analytics (batch ELT + medallion)

 ERP / CRM / HR databases ──► nightly incremental extract (ADF / Glue / Fivetran) ──► raw landing (S3 / ADLS)
   ──► Auto Loader bronze ──► dbt / Spark silver (SCD2 dimensions) ──► gold star schemas ──► Power BI / Tableau
   orchestrated by Airflow · governed by Unity Catalog / Lake Formation · data-quality gates between layers

13. Common Interview Questions

QuestionShort answer
Explain Lambda architecture.Two paths over the same immutable data: a batch layer that recomputes exact views from all history, a speed layer for low-latency views of recent data, and a serving layer that merges them
Explain Kappa architecture.A single stream-processing path over a replayable log; reprocessing is done by replaying the log through a new job version into new tables, then switching readers
What is the main drawback of Lambda?Duplicated logic in two systems that can diverge, plus the cost of running and reconciling both
When would you still choose Lambda today?When you need millisecond decisions and also exact, auditable results that absorb late corrections, e.g. fraud detection plus regulatory reporting
How do you reprocess history in Kappa?Start a new version of the job from the earliest offset (or the start of the bronze table), write to versioned outputs, reconcile, then switch views and retire the old version
What does Kappa require from the log?Enough retention (or tiered storage / a bronze table), ordering per key, and replay throughput; plus event-time, deterministic, idempotent processing
How is a lakehouse "Kappa-friendly"?Delta and Iceberg tables can be read as streams, so an append-only bronze table serves as the replayable log, and the same code runs continuously or as an availableNow batch
Medallion vs Lambda/Kappa?Medallion describes data quality layers (bronze, silver, gold); Lambda and Kappa describe processing paths. They are complementary
What is data mesh?An organisational approach: domain-owned data products on a self-serve platform with federated, automated governance
Why use the outbox pattern?To update a database and publish an event atomically: the event is written in the same transaction and published by CDC, avoiding dual-write inconsistencies
Log-based vs query-based CDC?Log-based reads the transaction log, capturing all changes including deletes with low source impact; query-based polls by timestamp and misses deletes and intermediate states
How do you make a streaming pipeline robust to bad records?Validate and route failures to a dead-letter table or topic with error details, alert on its volume, and allow fixing and replaying