Functional Pipeline Architecture

Quote

“The fundamental problem of state in data pipelines is not managing it — it is eliminating the need for it.”

Nathan Marz (creator of Apache Storm)

The Complete Architecture

flowchart TB
    API[API] -->|retry| LAND[Landing]
    LAND --> VAL_B{{Validate}}
    VAL_B -->|valid| BRONZE[(Bronze)]
    VAL_B -->|invalid| DLQ[Quarantine]
    BRONZE --> TX[Transforms]
    TX --> VAL_S{{Validate}}
    VAL_S -->|valid| SILVER[(Silver)]
    VAL_S -->|invalid| DLQ
    SILVER --> QG{{Quality Gate}}
    QG -->|pass| AGG[Aggregation]
    QG -->|fail| STOP[STOP]
    AGG --> GOLD[(Gold)]
    GOLD --> PQ[Parquet]
    PQ --> SERVE[API Server]

    BRONZE -.->|hash| LIN[(Lineage)]
    SILVER -.->|hash| LIN
    GOLD -.->|hash| LIN

    BRONZE -.->|context| CTX_S[Context]
    CTX_S -.->|accumulate| CTX_G[Context]
    CTX_G -.->|export| CON[Contract]

    style API fill:#1a1a2e,stroke:#e8b84d,color:#fff
    style LAND fill:#1a1a2e,stroke:#e8b84d,color:#fff
    style VAL_B fill:#1a1a2e,stroke:#34a853,color:#fff
    style VAL_S fill:#1a1a2e,stroke:#34a853,color:#fff
    style TX fill:#1a1a2e,stroke:#4285f4,color:#fff
    style AGG fill:#1a1a2e,stroke:#4285f4,color:#fff
    style QG fill:#1a1a2e,stroke:#cc4125,color:#fff
    style BRONZE fill:#1a1a2e,stroke:#e8b84d,color:#fff
    style SILVER fill:#1a1a2e,stroke:#4285f4,color:#fff
    style GOLD fill:#1a1a2e,stroke:#4285f4,color:#fff
    style PQ fill:#1a1a2e,stroke:#4285f4,color:#fff
    style SERVE fill:#1a1a2e,stroke:#4285f4,color:#fff
    style DLQ fill:#cc4125,stroke:#a33,color:#fff
    style STOP fill:#cc4125,stroke:#a33,color:#fff
    style LIN fill:#669df6,stroke:#4285f4,color:#fff
    style CTX_S fill:#1a4d2e,stroke:#34a853,color:#fff
    style CTX_G fill:#1a4d2e,stroke:#34a853,color:#fff
    style CON fill:#1a4d2e,stroke:#34a853,color:#fff

Diagram legend

Amber border — Imperative Shell (I/O, network, database) Blue border — Functional Core (pure transforms, no side effects) Green border — Contract Enforcement (typed validation at boundaries) Red border — Quality Gates and failure paths (quarantine, stop) Blue fill — Lineage tracking (batch_id, SHA-256 hash at every stage) Green fill — Context propagation (warnings accumulate bronze → silver → gold → contract)


Functional Core, Imperative Shell

Origin: Gary Bernhardt, “Boundaries” talk (2012). Originally a software architecture pattern for isolating side effects from business logic.

The principle: Transforms are pure functions — given the same input DataFrame, they produce the same output DataFrame, every time. No database calls, no HTTP requests, no file I/O, no logging inside the transform. Everything with side effects (API calls, SQL writes, file saves, retry logic) lives in the “imperative shell” that calls the pure functions and handles I/O around them.

Why it matters: Pure functions are trivially testable (no mocking needed), trivially parallelizable (no shared state), and trivially debuggable (reproduce the bug by passing the same input). If a transform produces wrong output, the bug is in the function — not in a network timeout, a database lock, or a stale credential.

The purity test

Can you call this function in a unit test with a hardcoded DataFrame and assert the output WITHOUT setting up a database, network, or file system? If yes — functional core. If no — imperative shell. The notebooks enforce this boundary: transforms take DataFrames and return DataFrames. Everything else is infrastructure.

Functional core (pure transforms):

Imperative shell (I/O boundaries):

Anti-pattern: transforms that call the database

A transform function that reads from SQL Server mid-computation, writes intermediate results to a file, or catches network errors — this is the shell leaking into the core. The transform becomes untestable without a live database and unpredictable under network failures. Keep I/O at the boundaries.

Move all I/O into the imperative shell — transforms take and return DataFrames only

A correctly structured transform has this signature: def compute_returns(df: pl.DataFrame) -> pl.DataFrame. No database handles, no file paths, no network calls. All lookups needed by the transform are pre-fetched in the shell and passed in as parameters. The transform is then testable with a hardcoded DataFrame — no infrastructure required.


Contract-First Validation

Origin: Data Contracts (Andrew Jones, 2022), Schema-on-Write (traditional RDBMS philosophy), Design by Contract (Bertrand Meyer, 1986).

The principle: Every stage boundary has a typed contract (Pydantic model / C# record + FluentValidation). Data that doesn’t conform is rejected BEFORE it crosses the boundary. The contract is code — version-controlled, unit-testable, and enforced at runtime.

Why it matters: Without contracts, bad data enters the pipeline silently. A renamed API field loads NULLs into bronze. A negative volume passes through to silver. A NaN daily return poisons the gold aggregation. By the time someone notices, the damage is three layers deep. Contracts catch bad data at ingestion — one layer, one fix.

Contract definitions:

Language comparison:

AspectPythonC#
Contract definitionPydantic BaseModel with Field()record with Data Annotations
Complex rules@model_validator, @field_validatorFluentValidation AbstractValidator<T>
Strictness modeConfigDict(strict=True) — no coercionCompile-time type safety + runtime validation
Error outputValidationError with field-level messagesValidationResult with Errors collection

See data-contracts for the broader contract theory and context-and-metadata-architecture > Schema Drift Detection for schema evolution patterns.


Quality Gate Pattern

Origin: Continuous Delivery (Jez Humble & David Farley, 2010). Originally a deployment concept — code must pass automated gates before reaching production. Applied here to data.

The principle: After each pipeline stage, automated assertions verify structural integrity, statistical bounds, and data freshness. If any assertion fails, the pipeline STOPS — downstream stages never see bad data. This is different from contract validation: contracts check individual rows at boundaries; quality gates check aggregate properties of the entire dataset after a stage completes.

CheckWhat It CatchesExample Threshold
Not emptyFailed fetch, empty API responselen(df) > 0
No null keysSchema drift, type coercion failuresymbol, date have 0 nulls
No duplicatesBad MERGE, double-fetch, dedup failure0 duplicate (symbol, date) pairs
Value rangeOutliers, data corruption, unit mismatchdaily_return within ±50%
FreshnessStale data, broken source, timezone bugLatest date within 5 days of today
Row countData loss, filter bug, source degradation>= minimum expected rows

Quality gate implementations:

See data-quality-framework for the quality dimension taxonomy and data-pipeline-testing-strategy > Data quality assertions for where quality gates fit in the testing pyramid.

Quality gates are not tests

Tests run in CI before deployment — they catch CODE bugs. Quality gates run in production after every pipeline execution — they catch DATA bugs. You need both. A pipeline with perfect tests but no quality gates will silently ingest corrupt data from a source that changed its schema.

Implement both CI tests and runtime quality gates

CI tests (pytest, dbt test) validate your code logic against known fixtures. Quality gates (row count, null check, freshness, range bounds) run after every pipeline execution in production and stop the next stage if data integrity fails. See data-pipeline-testing-strategy for where each check belongs in the testing pyramid.


Data Provenance and Lineage Tracking

Origin: W3C PROV Model (2013), Data Governance literature, blockchain-inspired tamper detection.

The principle: Every row carries a batch_id linking it to the pipeline run that created it. Every stage records a StageLineage object: start/end timestamps, input/output row counts, rejection counts, and a SHA-256 hash of the output. The RunContext captures the full execution envelope (symbols processed, date range, library versions, status). All of this is persisted alongside the data.

Why it matters: When a business user disputes a gold-layer number (“why did ASML’s momentum score drop?”), you trace backward: gold row → batch_id → silver stage lineage → bronze stage lineage → landing JSON file → API call timestamp. The SHA-256 hash provides cryptographic proof that data wasn’t modified between stages.

Sample lineage query:

SELECT stage, started_at, completed_at,
       input_rows, output_rows, rows_rejected, output_hash
FROM pipeline_lineage
WHERE batch_id = '9e43b0c5-a388-4ec8-ad76-bcde6e97d37e'
ORDER BY started_at;
stagestarted_atcompleted_atinput_rowsoutput_rowsrejectedoutput_hash
bronze_ingest08:00:0108:00:04133113292a3f8b2…
silver_enrich08:00:0408:00:061329132907c1d4e…
gold_aggregate08:00:0608:00:071329500e9f2a1…

Lineage implementations:

See context-and-metadata-architecture for the broader provenance theory including bi-temporal modeling and context propagation patterns.


Immutable Value Objects

Origin: Domain-Driven Design (Eric Evans, 2003), Functional Programming.

The principle: Pydantic models with strict=True and C# record types are immutable by default. Once created, they cannot be modified — you create a new instance with different values. This eliminates mutation bugs where a transform accidentally modifies its input. Combined with value equality (two records with identical fields are equal), immutability enables reliable change detection for SCD2 upserts and MERGE operations.

Implementations: The contract models from the Contract-First Validation section (above) serve double duty — they are both validation contracts AND immutable value objects. The SCD2 upsert logic relies on this:

Immutability enables change detection

SCD Type 2 upserts need to compare “current vs incoming” to detect changes. With mutable objects, comparing two instances requires field-by-field checks that may miss newly added fields. With records/immutable models, value equality is built in — old == new compares every field automatically. If they differ, close the old record and insert the new one.


The Quarantine Pattern

The principle: Rows that fail validation are not discarded — they’re persisted to a quarantine table with the batch_id, stage, raw data, and full error message. This enables investigation (why did these rows fail?), replay (fix the source and re-ingest), and metrics (what percentage of rows fail per source? is it getting worse?).

Implementations:

See error-handling-and-retry-patterns for broader error handling theory and data-quality-framework > Data Quality Quarantine Pattern for the quarantine pattern in the quality framework.

Never silently drop bad rows

if not valid: continue is the most dangerous line in a data pipeline. The row disappears. Nobody knows it existed. The row count drops by one. Weeks later, someone asks why a symbol is missing from the gold report. Always quarantine — the 5 lines of code to persist rejected rows save hours of investigation.

Persist every rejected row to the quarantine table with full error context

Call quarantine_row(batch_id, stage, raw_data, error_message) for every row that fails validation. The quarantine table records what the row contained, which stage rejected it, and why. This enables investigation, replay after a fix, and quality metrics (rejection rate per source over time).


Two Dimensions of Data Trustworthiness

The five principles above — functional core, contract validation, quality gates, lineage, immutability — form the structural dimension. They ensure data is correct: typed, validated, auditable, and reproducible at every stage.

But correct data is not necessarily useful data. A gold table with volatility: 0.0187 is structurally perfect — it has a type, a hash, a lineage record, and it passed all quality gates. Yet no downstream consumer can interpret it without reading the pipeline source code.

The semantic dimension cuts across stages: column registries explain what each field means, business context records why the run happened, temporal context separates data date from load date, and warnings accumulate from bronze to gold. Together the two dimensions make data trustworthy.

The Intersection

Structural integrity answers: “Is this data correct?” Semantic integrity answers: “What does this data mean?” A trustworthy pipeline delivers both — verifiably correct AND self-describing.


Context Architecture — Semantic Metadata Layer

Context is metadata that flows THROUGH the pipeline alongside the data, growing richer at each stage. Unlike lineage (recorded after the fact), context is created at stage start and carried forward. By gold, the context contains the accumulated knowledge from every upstream stage.

ColumnContext — What Each Value Means

Each column carries structured metadata: description, unit, computation formula, source columns, null semantics, and valid range. Without it, volatility: 0.0187 is an opaque number. With it: “daily σ of close-to-close returns, decimal_ratio, annualize with √252.”

Without Semantic Context

An AI agent queries gold_symbol_profile and sees volatility: 0.0187. It doesn’t know if that’s a percentage or a decimal, daily or annual, what formula produced it, or what NULL would mean. The data contract eliminates this: unit=decimal_ratio, formula=std(daily_return), annualize with √252. The number becomes self-describing.

Attach a ColumnContext record to every computed column

For each column in a gold table, define a ColumnContext with description, unit, formula, source_columns, and null_semantics. Export these as x-column-context in the JSON Schema contract. Any consumer — a dashboard, another pipeline, an LLM — can interpret every value correctly without reading the pipeline source code.

BusinessContext — Why This Run Happened

Records the trigger (scheduled, manual, backfill, reprocess), the is_correction flag, and the business date. Without it, two batches covering the same date range are indistinguishable — was the second a correction or a duplicate?

TemporalContext — Bi-Temporal Markers

Separates as_of_date (what date is this data FOR) from knowledge_date (when did we learn about it). Without it, a backfill loading 2024 data in 2026 looks like a normal 2026 run. See context-and-metadata-architecture > Temporal Context — As of When Is This Data True? for the broader theory.

StageContext — The Propagation Carrier

The carrier that propagates all context through the pipeline via for_next_stage(). Each stage inherits upstream warnings and adds its own. By gold, the context contains the full warning chain from every stage.

Warning Accumulation

Bronze records “116 zero-volume rows detected.” Silver inherits that warning and adds “95 SMA-20 NULLs (first 19 rows × 5 symbols).” Gold inherits both. Any consumer reading gold context sees the full chain without querying intermediate tables.

Context Persistence

Context is persisted to context_log in SQL Server — it survives the Python/C# process exit. Query it for any batch to reconstruct the full semantic state at each stage.

Implementations:

ComponentPythonC#
ColumnContext modelpycs
Column registriespycs
BusinessContext modelpycs
TemporalContext modelpycs
StageContext modelpycs
Context persistencepycs
context_log DDLpy(same DDL)

Data Contracts as Consumer-Facing Output

The Contract-First Validation section above covers contracts as INPUT validation — rejecting bad data at boundaries. This section covers contracts as OUTPUT — making gold values self-describing for any consumer.

The pipeline exports a JSON Schema file per gold table, enriched with x-column-context — the column registries serialized as structured metadata alongside the schema. Any consumer — a dashboard, another pipeline, an LLM agent — can interpret every value correctly without reading the pipeline source code.

{
  "x-column-context": [
    {
      "name": "volatility",
      "description": "Std dev of daily returns — annualize with √252",
      "unit": "decimal_ratio",
      "computation": "std(daily_return) per symbol",
      "source_columns": ["silver.daily_return"],
      "null_semantics": "insufficient_data"
    }
  ]
}

Contracts Turn Numbers Into Knowledge

Without the contract, volatility: 0.0187 requires reading the pipeline source. With the contract, any consumer reads: unit=decimal_ratio, formula=std(daily_return), annualize with √252 → 29.7% annual volatility. The data is self-describing.

Implementations:

ComponentPythonC#
Contract exportpycs
Contract inspectionpycs

See data-contracts for the broader contract specification theory. See ai-augmented-data-engineering > Self-Describing Data for AI Consumers for how AI agents consume these contracts in practice.


Context-Driven Decisions — Real Data Proof

Context architecture produces real value through the pipeline’s own data — not hypothetical scenarios, but actual output where context answered a question that the data alone couldn’t.

Zero-Volume Classification

116 Silver rows have volume=0. Without context, each is an undifferentiated alert. The pipeline cross-referenced each zero-volume date against dim_calendar at bronze ingestion and recorded the classification (non-trading day vs genuine anomaly) as a context warning. The warning propagates through silver and gold — consumers know WHY volume is zero without investigating.

SMA-20 Null Accounting

sma_20 has 95 NULLs — exactly 19 × 5 symbols (the first 19 rows per symbol lack enough history for a 20-day average). Context recorded “95 NULL values (first 19 rows per symbol)” at silver stage. If any symbol had MORE than 19, those extras would be unexplained. Context draws the line between expected and unexpected NULLs.

Contract Interpretation

The AI agent scenario: volatility: 0.0187 is meaningless without the contract. With x-column-context, the consumer reads: unit=decimal_ratio, formula=std(daily_return), annualize with √252 → 29.7%. No source code reading required.

Implementations:

DemonstrationPythonC#
Zero-volume classificationpycs
SMA-20 null accountingpycs
Contract interpretationpycs

Context Makes Data Self-Describing

Lineage traces data BACKWARD through the pipeline — where did this row come from? Context explains data FORWARD to any consumer — what does this value mean? Together they make data trustworthy: verifiably correct AND self-describing.

See ai-augmented-data-engineering for how AI agents consume context-enriched data.


Implementation Comparison

ConceptPythonC#
ValidationPydantic v2 BaseModelrecord + FluentValidation
TransformsPolars expressionsLINQ
Retrytenacity @retryPolly WaitAndRetryAsync
DB accesspyodbc + SQLAlchemyDapper
Hashinghashlib.sha256SHA256.HashData
ServingFastAPIASP.NET Core (HttpListener)
ImmutabilityConfigDict(strict=True)record types (default immutable)
Serializationmodel_dump_json()JsonSerializer.Serialize()
Batch IDuuid.uuid4()Guid.NewGuid()
Upsertpyodbc MERGE statementDapper Execute() with MERGE
Quality checksCustom dq_check_* functionsCustom Dq* assertion functions
Dead letter queuequarantine_row() → SQL ServerQuarantineRow() → SQL Server
Column contextColumnContext(BaseModel)ColumnContext record
Business contextBusinessContext(BaseModel)BusinessContext record
Context propagationStageContext.for_next_stage()StageContext.ForNextStage()
Data contract exportexport_contracts() → JSON SchemaExportContracts() → JSON
Context persistencepersist_context() → context_logPersistContext() → context_log