AAtsushi's Blog
Data Engineering

Designing a Data Pipeline for Massive Tables That Scales with Minimal Code

→ 日本語版を読む

This article was written using AI.

Overview

In data engineering practice, managing pipelines becomes unmanageable as the number of tables to ingest grows to hundreds or thousands. This article explains how to use a Config-driven pipeline design approach to scale a large number of tables on Databricks with minimal code.


The Common Problem: Code Grows Every Time a Table Is Added

When pipelines are written per table, the following problems accumulate.

Similar code appears everywhere

# Pipeline for orders
def ingest_orders():
    df = spark.read.format("jdbc").option("url", ...).option("query", "SELECT * FROM orders").load()
    df.write.format("delta").mode("append").saveAsTable("bronze.orders")

# Pipeline for contacts (almost identical)
def ingest_contacts():
    df = spark.read.format("jdbc").option("url", ...).option("query", "SELECT * FROM contacts").load()
    df.write.format("delta").mode("append").saveAsTable("bronze.contacts")

# Pipeline for events (also almost identical)
def ingest_events():
    ...

If there are 10 tables, you end up with 10 nearly identical functions. When the connection URL changes, you have to fix it in 10 places, and missed fixes become a source of bugs.

A Python file has to be written every time a new table is added

Even to add a single table, the full cycle of creating a new Python file, going through review, and deploying it happens every time. The operational cost is high, and mistakes are easy to make.

Quality checks and error handling are implemented differently across each pipeline

When you want to fix quality check logic, you have to go and update every single pipeline. The larger the team, the more inconsistency in implementation becomes apparent.


Prerequisite 1: What Are Bronze, Silver, and Gold Layers?

Before getting into the solution, let me clarify the layer concept used in this article.

In data lake architecture, data is managed in three layers based on the degree of processing applied.

Source (DB/API/Files)
     │
     ▼
Bronze Layer: Raw data stored as-is. No transformation.
     │
     ▼
Silver Layer: Cleansed and joined data. Ready for analysis.
     │
     ▼
Gold Layer: Data aggregated with business logic. Directly connected to reports and BI.

Separating into layers makes it clear "how much the data has been processed," making it easier to isolate the cause when something goes wrong.

The transformation pattern differs by layer.

LayerTransformation PatternReason
BronzeOne-to-one onlyFaithfully ingests the source. No transformation.
SilverFunnel (many-to-one)Joins and cleanses Bronze tables.
GoldFunnel (many-to-one)Aggregates Silver tables with business logic.

The funnel here refers to a transformation pattern that aggregates multiple tables into a single table.

bronze.orders   ─→  silver.orders   ─╮
                                      ├─→ gold.order_summary (funnel)
bronze.contacts ─→  silver.contacts ─╯

Note that fan-out (a pattern that creates multiple tables from a single source) can be expressed by independently defining multiple funnels that reference the same source. There is no need to have it as a dedicated pattern.


Prerequisite 2: Technology Stack

This article assumes the following technology stack.

TechnologyPurpose
DatabricksExecution environment. Provides Spark cluster and Delta Lake.
Databricks JobsHandles scheduled execution, parallel execution, and dependency management for pipelines.
Delta LakeTable format for writing. Supports updates, deletes, and upserts.
PySparkData processing. Spark DataFrames use lazy evaluation, enabling distributed processing of large datasets.
PythonLanguage for implementing pipeline logic.
YAMLConfiguration file format for pipelines.
GitHub Actions / Databricks Asset BundleRegisters and updates Jobs from CI/CD.

Using Databricks Jobs allows you to declaratively define dependencies between tables and run them in parallel. If one task fails, it doesn't affect others, and retries can be applied individually.

The Solution: Config-Driven Pipelines

The idea behind config-driven design is simple: "What differs per table is only the configuration. The processing code is shared as a single common implementation."

For example, to ingest the orders table from PostgreSQL, all you write is this:

tables:
  - name: raw_orders
    source_type: postgres
    query: "SELECT * FROM orders WHERE updated_at > '{last_run}'"
    destination: bronze.orders
    schedule: "0 * * * *"    # every hour at minute 0
    load_mode: append
    quality:
      on_failure: error
      checks:
        - type: not_null
          columns: [order_id]

To add a new table, you just add one entry to the YAML. You don't touch Python code. Change source_type and it can handle Salesforce or S3. Use load_mode to switch between full overwrite, incremental update, or append. Use schedule to configure execution timing per table individually, and tables with the same schedule are automatically grouped into the same Job. Quality check configuration is also written in the same file and executed automatically.

When you want to fix quality check logic, modifying just one file in the common module reflects the change across all tables. Details are covered in later sections.


Overall Structure

Here is an overview of the processing flow for each layer. The Bronze layer simply stores raw data as-is, so no transformation occurs. The Silver/Gold layers add join and transformation processing that references multiple Bronze or Silver tables.

Bronze Layer

Source (PostgreSQL, Salesforce, S3, etc.)
     │ Extract   ← fetch data from source
     ▼
 Raw data
     │ Quality Check   ← data quality check
     ▼
 Quality-verified data
     │ Load   ← write to Delta table
     ▼
 bronze.table (Delta Table)

Silver/Gold Layer — Transform is added on top of the Bronze layer processing.

bronze/silver.table_A  bronze/silver.table_B  …
           │                        │
           └───────────┬────────────┘
                       │ Extract   ← fetch from multiple sources
                       ▼
               Multiple DataFrames
                       │ Transform   ← join, cleanse, aggregate
                       ▼
               Transformed DataFrame
                       │ Quality Check
                       ▼
               Quality-verified data
                       │ Load
                       ▼
               silver/gold.table (Delta Table)

The implementations of Extract, Quality Check, and Load are shared as common modules (sources/ / quality.py / loader.py) across both layers. "Which source to fetch from," "what checks to run," and "which pattern to write with" are all written in YAML configuration, and notebooks read the config to switch behavior. Behavior is controlled purely by configuration, without changing code.

Since each role has its own independent directory, it's immediately clear where to make changes.

pipeline/
├── config/
│   ├── models.py                 # definition of data objects for reading YAML
│   ├── bronze_config.yaml        # Bronze layer: declares tables to ingest
│   └── silver_gold_config.yaml   # Silver/Gold layer: declares tables to transform
├── sources/
│   ├── base.py                   # interface definition for "fetching data"
│   ├── postgres.py               # implementation for fetching from PostgreSQL
│   ├── salesforce.py             # implementation for fetching from Salesforce
│   ├── s3.py                     # implementation for fetching from S3
│   ├── delta.py                  # implementation for fetching from DeltaTable
│   └── registry.py               # mapping of which class to use for each source type
├── transforms/
│   ├── orders.py                 # transformation logic for the orders table
│   └── order_summary.py          # transformation logic for the order_summary table
├── quality.py                    # data quality checks (common)
├── loader.py                     # writing to Delta tables (common)
├── job_deployer.py               # Job registration logic for Databricks Jobs (common)
└── deploy.py                     # entry point executed from CI/CD

Common Modules

These are modules shared across both the Bronze and Silver/Gold layers. Understanding them here will make it easier to follow the per-layer implementations later.

config/models.py — Configuration Data Objects

If you pass YAML-loaded results as raw dict through the code, typos and missing settings go unnoticed until runtime, and it becomes hard to track which keys each function needs. By defining them as data objects using dataclass, IDE autocompletion works and the intent of the code becomes clear.

from dataclasses import dataclass, field

@dataclass
class QualityCheck:
    type: str
    columns: list[str] = field(default_factory=list)
    threshold: int = 0
    column: str = ""
    min: float | None = None

@dataclass
class QualityConfig:
    on_failure: str = "error"
    checks: list[QualityCheck] = field(default_factory=list)

@dataclass
class SourceConfig:
    type: str
    alias: str
    url: str = ""
    table: str = ""
    query: str = ""
    path: str = ""

@dataclass
class TableConfig:
    name: str
    destination: str
    load_mode: str = "overwrite"
    quality: QualityConfig = field(default_factory=QualityConfig)
    # for Bronze layer
    source_type: str = ""
    schedule: str = ""          # cron format. e.g.: "0 * * * *" (hourly), "0 6 * * *" (daily at 6am)
    # for Silver/Gold layer
    sources: list[SourceConfig] = field(default_factory=list)
    transform: str = ""
    depends_on: list[str] = field(default_factory=list)
    merge_keys: list[str] = field(default_factory=list)

def load_config(path: str) -> list[TableConfig]:
    import yaml
    from dacite import from_dict
    with open(path) as f:
        raw = yaml.safe_load(f)
    return [from_dict(TableConfig, t) for t in raw["tables"]]

dacite is a library that converts nested dictionaries into dataclass instances. If you want to strengthen validation, the advanced section introduces how to migrate to pydantic.

sources/ — Fetching Data

The fetch logic for each data source is extracted into classes. ExtractorProtocol is an interface that defines the methods all Extractors must implement. Using Python's Protocol eliminates the need for inheritance, so existing classes can be plugged in as-is.

sources/base.py

from typing import Protocol
from pyspark.sql import DataFrame
from config.models import SourceConfig

class ExtractorProtocol(Protocol):
    def extract(self, config: SourceConfig) -> DataFrame:
        ...

sources/postgres.py

from pyspark.sql import DataFrame
from config.models import SourceConfig

class PostgresExtractor:
    def extract(self, config: SourceConfig) -> DataFrame:
        return (
            spark.read.format("jdbc")
            .option("url", config.url)
            .option("query", config.query)
            .load()
        )

The mapping of "which class to use for which source type" is centralized in registry.py. To add a new data source, just add an Extractor class under sources/ and register it with one line in registry.py. No need to modify notebooks or other modules.

For example, to add BigQuery, it would look like this:

sources/registry.py

from sources.base import ExtractorProtocol
from sources.postgres import PostgresExtractor
from sources.salesforce import SalesforceExtractor
from sources.s3 import S3Extractor
from sources.delta import DeltaExtractor
# from sources.bigquery import BigQueryExtractor  ← just add this

EXTRACTORS: dict[str, ExtractorProtocol] = {
    "postgres": PostgresExtractor(),
    "salesforce": SalesforceExtractor(),
    "s3": S3Extractor(),
    "delta": DeltaExtractor(),
    # "bigquery": BigQueryExtractor(),  ← just add this
}

This style is known as the Strategy pattern. Instead of writing branching logic with if/elif chains, processing is encapsulated in classes and switched via a dictionary — a readable and extensible approach. To add a new pattern, just write one class and register it in EXTRACTORS with one line, without touching existing code.

The Silver/Gold layers frequently read from Bronze Delta Tables, so here is an implementation example for DeltaExtractor as well.

sources/delta.py

from pyspark.sql import DataFrame
from config.models import SourceConfig

class DeltaExtractor:
    def extract(self, config: SourceConfig) -> DataFrame:
        return spark.read.format("delta").table(config.table)

quality.py — Data Quality Checks

Executes the checks declared in the configuration file. on_failure: error stops the pipeline when a check fails; on_failure: warn outputs a warning log and continues.

Check types are managed in a dictionary (CHECKERS). Like sources/ and registry.py, it uses the Strategy pattern — to add a new check, just write one class and register it in CHECKERS with one line, without touching existing code.

import logging
from typing import Protocol, TypedDict
from pyspark.sql import DataFrame
from config.models import TableConfig, QualityCheck

logger = logging.getLogger(__name__)

class CheckResult(TypedDict):
    passed: bool
    message: str

class CheckerProtocol(Protocol):
    def check(self, df: DataFrame, rule: QualityCheck) -> CheckResult:
        ...

class NotNullChecker:
    def check(self, df: DataFrame, rule: QualityCheck) -> CheckResult:
        for col in rule.columns:
            null_count: int = df.filter(df[col].isNull()).count()
            if null_count > 0:
                return {"passed": False, "message": f"{col} has {null_count} NULL value(s)"}
        return {"passed": True, "message": ""}

class MinRowsChecker:
    def check(self, df: DataFrame, rule: QualityCheck) -> CheckResult:
        row_count: int = df.count()
        if row_count < rule.threshold:
            return {"passed": False, "message": f"Row count is insufficient ({row_count} < {rule.threshold})"}
        return {"passed": True, "message": ""}

class ValueRangeChecker:
    def check(self, df: DataFrame, rule: QualityCheck) -> CheckResult:
        if rule.min is not None:
            violation: int = df.filter(df[rule.column] < rule.min).count()
            if violation > 0:
                return {"passed": False, "message": f"{rule.column} has {violation} out-of-range value(s)"}
        return {"passed": True, "message": ""}

CHECKERS: dict[str, CheckerProtocol] = {
    "not_null": NotNullChecker(),
    "min_rows": MinRowsChecker(),
    "value_range": ValueRangeChecker(),
}

def run_checks(df: DataFrame, config: TableConfig) -> None:
    on_failure: str = config.quality.on_failure

    for rule in config.quality.checks:
        checker = CHECKERS.get(rule.type)
        if checker is None:
            raise ValueError(f"Unsupported check: {rule.type}")
        result: CheckResult = checker.check(df, rule)
        if not result["passed"]:
            message: str = f"[{config.name}] Quality check failed: {result['message']}"
            if on_failure == "error":
                raise ValueError(message)
            else:
                logger.warning(message)

loader.py — Writing to Delta Tables

A module that centralizes write processing. Multiple write patterns — full overwrite, incremental update, append, and partition-level overwrite — are implemented using the Strategy pattern.

from typing import Protocol
from pyspark.sql import DataFrame
from delta.tables import DeltaTable
from config.models import TableConfig

class LoaderProtocol(Protocol):
    def load(self, df: DataFrame, config: TableConfig) -> None:
        ...

class OverwriteLoader:
    def load(self, df: DataFrame, config: TableConfig) -> None:
        df.write.format("delta").mode("overwrite").saveAsTable(config.destination)

class AppendLoader:
    def load(self, df: DataFrame, config: TableConfig) -> None:
        df.write.format("delta").mode("append").saveAsTable(config.destination)

class MergeLoader:
    def load(self, df: DataFrame, config: TableConfig) -> None:
        merge_condition = " AND ".join([f"target.{k} = source.{k}" for k in config.merge_keys])
        delta_table = DeltaTable.forName(spark, config.destination)
        (
            delta_table.alias("target")
            .merge(df.alias("source"), merge_condition)
            .whenMatchedUpdateAll()
            .whenNotMatchedInsertAll()
            .execute()
        )

class OverwritePartitionLoader:
    def load(self, df: DataFrame, config: TableConfig) -> None:
        (
            df.write.format("delta")
            .mode("overwrite")
            .option("replaceWhere", f"{config.partition_column} = '{config.partition_value}'")
            .saveAsTable(config.destination)
        )

LOADERS: dict[str, LoaderProtocol] = {
    "overwrite": OverwriteLoader(),
    "append": AppendLoader(),
    "merge": MergeLoader(),
    "overwrite_partition": OverwritePartitionLoader(),
}

def load(df: DataFrame, config: TableConfig) -> None:
    loader = LOADERS.get(config.load_mode)
    if loader is None:
        raise ValueError(f"Unsupported load_mode: {config.load_mode}")
    loader.load(df, config)

Four patterns can be specified for load_mode. Add loader classes as needed.

load_modeBehaviorUse Case
overwriteFull overwriteMaster data, small tables
mergeUpdate on key match, insert if not found (Upsert)Transaction data, data with changes
appendAppends to existing data onlyLogs, event data
overwrite_partitionOverwrites only the specified partitionDaily batch where only a specific date needs to be updated

Bronze Layer: One-to-One Pipeline

Faithfully ingests the source with no transformation. Specify which Extractor to use with source_type and write directly to the Delta table.

config/bronze_config.yaml

tables:
  - name: raw_orders
    source_type: postgres
    query: "SELECT * FROM orders WHERE updated_at > '{last_run}'"
    destination: bronze.orders
    schedule: "0 * * * *"       # every hour at minute 0
    load_mode: append            # append new records
    quality:
      on_failure: error          # stop pipeline on check failure
      checks:
        - type: not_null
          columns: [order_id]
        - type: min_rows
          threshold: 1

  - name: raw_contacts
    source_type: salesforce
    source_object: Contact
    destination: bronze.contacts
    schedule: "0 * * * *"       # every hour at minute 0 (grouped into the same Job as raw_orders)
    load_mode: overwrite         # full overwrite every time
    quality:
      on_failure: warn           # on check failure, only output a warning log and continue
      checks:
        - type: not_null
          columns: [Id]

  - name: raw_events
    source_type: s3
    path: s3://my-bucket/events/
    destination: bronze.events
    schedule: "0 6 * * *"       # daily at 6am (becomes a separate Job)
    load_mode: append
    quality:
      on_failure: warn
      checks:
        - type: not_null
          columns: [event_id]

bronze_task Notebook

Each Job task registered by deploy.py calls a notebook. Since each table runs as an independent task in parallel, one failure doesn't affect others. It receives table_name as a parameter, loads the configuration, and executes Extract → Quality Check → Load.

from pyspark.sql import DataFrame
from sources.registry import EXTRACTORS
from quality import run_checks
from loader import load
from config.models import TableConfig, load_config

# Receive the table name passed as a parameter from Databricks Jobs
table_name: str = dbutils.widgets.get("table_name")
table: TableConfig = next(t for t in load_config("config/bronze_config.yaml") if t.name == table_name)

# Extract: retrieve the Extractor corresponding to source_type and execute
df: DataFrame = EXTRACTORS[table.source_type].extract(table)

# Quality Check
run_checks(df, table)

# Load
load(df, table)


Silver/Gold Layer: Funnel Pipeline

References multiple tables for joining and transformation. All transformation patterns are expressed as funnels (many-to-one). Even when there is only one source, writing it as a funnel means you won't need to change the transform function signature if you add more sources later.

config/silver_gold_config.yaml

List the dependent table names in depends_on. Tables with no dependencies are run in parallel by Databricks Jobs; tables with dependencies run after their dependencies complete.

tables:
  - name: silver_orders
    depends_on: []                        # no dependencies → runs immediately after Bronze completes
    sources:
      - type: delta
        table: bronze.orders
        alias: orders                     # referenced as dfs["orders"] inside the transform
    destination: silver.orders
    transform: transforms.orders.clean
    load_mode: merge
    merge_keys: [order_id]
    quality:
      on_failure: error
      checks:
        - type: not_null
          columns: [order_id, amount]
        - type: min_rows
          threshold: 1000
        - type: value_range
          column: amount
          min: 0

  - name: silver_contacts
    depends_on: []                        # no dependencies → runs concurrently with silver_orders
    sources:
      - type: delta
        table: bronze.contacts
        alias: contacts
    destination: silver.contacts
    transform: transforms.contacts.clean
    load_mode: overwrite
    quality:
      on_failure: warn
      checks:
        - type: not_null
          columns: [contact_id]

  - name: gold_order_summary
    depends_on: [silver_orders, silver_contacts]  # runs after both complete
    sources:
      - type: delta
        table: silver.orders
        alias: orders
      - type: delta
        table: silver.contacts
        alias: contacts
    destination: gold.order_summary
    transform: transforms.order_summary.build
    load_mode: overwrite
    quality:
      on_failure: error
      checks:
        - type: not_null
          columns: [order_id, contact_id]

transforms/ — Transformation Logic

All transform functions receive dfs: dict[str, DataFrame] and return a single DataFrame. Each key in dfs corresponds to the alias in the configuration file. Even when there is only one source, writing with the same signature means the transform function definition won't need to change if more sources are added later.

transforms/orders.py — Funnel with a single source

from pyspark.sql import DataFrame
from pyspark.sql import functions as F
from pyspark.sql.window import Window

def clean(dfs: dict[str, DataFrame]) -> DataFrame:
    df = dfs["orders"]   # retrieve using the alias key
    return (
        df
        .drop("_internal_id", "_raw_payload")
        .withColumn("amount", F.col("amount").cast("double"))
        .withColumn("ordered_at", F.to_timestamp("ordered_at", "yyyy-MM-dd HH:mm:ss"))
        .filter(F.col("order_id").isNotNull())
        .filter(F.col("amount") > 0)
        .withColumn("status", F.lower(F.trim(F.col("status"))))
        .withColumn("order_year", F.year("ordered_at"))
        .withColumn("order_month", F.month("ordered_at"))
        .withColumn(
            "row_num",
            F.row_number().over(
                Window.partitionBy("order_id").orderBy(F.desc("ordered_at"))
            )
        )
        .filter(F.col("row_num") == 1)
        .drop("row_num")
    )

transforms/order_summary.py — Funnel with multiple sources

from pyspark.sql import DataFrame

def build(dfs: dict[str, DataFrame]) -> DataFrame:
    orders = dfs["orders"]
    contacts = dfs["contacts"]
    return (
        orders
        .join(contacts, orders["customer_id"] == contacts["Id"], "left")
        .select("order_id", "contact_id", "amount", "ordered_at")
    )

silver_gold_task Notebook

Receives table_name as a parameter, loads the configuration, and executes Extract → Transform → Quality Check → Load. silver_orders and silver_contacts run in parallel, and gold_order_summary runs after both complete.

silver_orders   ─╮
                  ├─→ gold_order_summary
silver_contacts ─╯
import importlib
from types import ModuleType
from typing import Callable
from pyspark.sql import DataFrame
from sources.registry import EXTRACTORS
from quality import run_checks
from loader import load
from config.models import TableConfig, load_config

# Receive the table name passed as a parameter from Databricks Jobs
table_name: str = dbutils.widgets.get("table_name")
table: TableConfig = next(t for t in load_config("config/silver_gold_config.yaml") if t.name == table_name)

# Extract: retrieve multiple sources as a dictionary keyed by alias
# Spark DataFrames use lazy evaluation, so data is not actually read at this point.
# Data is actually processed when an action is executed inside the transform.
dfs: dict[str, DataFrame] = {source.alias: EXTRACTORS[source.type].extract(source.table) for source in table.sources}

# Transform: parse the string "transforms.orders.clean" from YAML into module and function name and dynamically load
# → calls the clean() function in transforms/orders.py at runtime
module_path, func_name = table.transform.rsplit(".", 1)
module: ModuleType = importlib.import_module(module_path)
transform_fn: Callable[[dict[str, DataFrame]], DataFrame] = getattr(module, func_name)
df: DataFrame = transform_fn(dfs)

# Quality Check
run_checks(df, table)

# Load
load(df, table)


Deployment: Registering with Databricks Jobs

Job registration is done from the CI/CD pipeline (GitHub Actions, Databricks Asset Bundle, etc.). It is re-run when the configuration file changes (e.g., tables added or removed) to update the Jobs.

Registration logic is consolidated in job_deployer.py. Bronze tables are grouped by schedule from bronze_config.yaml, and one Job is created per schedule. Silver/Gold tasks that depend on the corresponding Bronze are automatically added as downstream tasks to each Job. Since all tables share the same notebook and switch the table to process via the table_name parameter, even with 100 tables you only need 2 notebooks.

After registration, the Job task graph looks like this (assuming raw_orders and raw_contacts run hourly, and raw_events runs daily):

pipeline_0_*_*_*_* (hourly)
  ├── [Bronze] raw_orders
  ├── [Bronze] raw_contacts
  ├── [Silver] silver_orders    depends_on: [raw_orders]
  └── [Silver] silver_contacts  depends_on: [raw_contacts]

pipeline_0_6_*_*_* (daily)
  ├── [Bronze] raw_events
  ├── [Silver] silver_events        depends_on: [raw_events]
  └── [Gold]   gold_order_summary   depends_on: [silver_orders, silver_contacts, silver_events, raw_events]
                                     ↑ raw_events in depends_on assigns this to this Job
                                     ↑ silver_orders and silver_contacts are assumed to have already completed in the hourly Job

How to write depends_on in silver_gold_config.yaml

depends_on can include both Silver/Gold table names (for task ordering) and Bronze table names (for Job group assignment). An entry with a Bronze name means "put me in the Job that runs the schedule for that Bronze."

# When depending only on Bronze with the same schedule
- name: silver_orders
  depends_on: [raw_orders]        # Bronze name → goes into hourly Job + task ordering

# When waiting for Bronze with a different schedule
- name: gold_order_summary
  depends_on: [silver_orders, silver_contacts, silver_events, raw_events]
  #            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^  S/G inter-dependencies (task ordering)
  #                                                                 ^^^^^^^^^^
  #                                                                 Bronze name → goes into daily Job

job_deployer.py

from databricks.sdk import WorkspaceClient
from databricks.sdk.service.jobs import (
    TaskDependency,
    NotebookTask,
    Task,
    CronSchedule,
)
from config.models import TableConfig

def _bronze_names(bronze_tables: list[TableConfig]) -> set[str]:
    """Returns a set of Bronze task names. e.g.: {"raw_orders", "raw_contacts"}"""
    return {t.name for t in bronze_tables}

def _sg_tables_for_group(
    sg_tables: list[TableConfig],
    group_bronze_names: set[str],
    all_bronze_names: set[str],
) -> list[TableConfig]:
    """Returns the Silver/Gold tables that should be included in this Bronze group.

    Starting from those whose depends_on directly contains a Bronze name in this group,
    performs BFS over S/G inter-dependencies to track transitive dependencies as well.
    """
    # Start with those that directly contain a Bronze name in this group in depends_on
    directly_dependent: set[str] = {
        t.name
        for t in sg_tables
        if group_bronze_names & set(t.depends_on)
    }

    # Transitively dependent (BFS)
    # Only traverse S/G inter-dependencies (Bronze names are excluded as they are used for group assignment)
    included: set[str] = set(directly_dependent)
    frontier = list(directly_dependent)
    while frontier:
        current = frontier.pop()
        for t in sg_tables:
            if t.name in included:
                continue
            sg_deps = [d for d in t.depends_on if d not in all_bronze_names]
            if current in sg_deps:
                included.add(t.name)
                frontier.append(t.name)

    return [t for t in sg_tables if t.name in included]

def _build_bronze_tasks(
    bronze_tables: list[TableConfig],
    notebook_path: str,
) -> list[Task]:
    return [
        Task(
            task_key=t.name,
            depends_on=[],
            notebook_task=NotebookTask(
                notebook_path=notebook_path,
                base_parameters={"table_name": t.name},
            ),
        )
        for t in bronze_tables
    ]

def _build_sg_tasks(
    sg_tables: list[TableConfig],
    notebook_path: str,
) -> list[Task]:
    """Builds Silver/Gold tasks.

    Values from depends_on in YAML are converted directly to TaskDependency.
    Both Bronze names and S/G names are treated as task dependencies,
    and Databricks Jobs controls the order according to the task graph.
    """
    return [
        Task(
            task_key=t.name,
            depends_on=[TaskDependency(task_key=dep) for dep in t.depends_on],
            notebook_task=NotebookTask(
                notebook_path=notebook_path,
                base_parameters={"table_name": t.name},
            ),
        )
        for t in sg_tables
    ]

def deploy_jobs_by_schedule(
    bronze_tables: list[TableConfig],
    sg_tables: list[TableConfig],
    bronze_notebook_path: str,
    sg_notebook_path: str,
) -> None:
    """Groups Bronze by schedule and registers the corresponding Silver/Gold
    as downstream tasks in the same Job.
    """
    all_bronze_names = _bronze_names(bronze_tables)

    # Group by schedule
    schedule_groups: dict[str, list[TableConfig]] = {}
    for t in bronze_tables:
        schedule_groups.setdefault(t.schedule, []).append(t)

    client = WorkspaceClient()

    for schedule, group_bronze in schedule_groups.items():
        group_bronze_names = _bronze_names(group_bronze)
        group_sg = _sg_tables_for_group(sg_tables, group_bronze_names, all_bronze_names)

        tasks: list[Task] = (
            _build_bronze_tasks(group_bronze, bronze_notebook_path)
            + _build_sg_tasks(group_sg, sg_notebook_path)
        )

        job_name = f"pipeline_{schedule.replace(' ', '_')}"

        client.jobs.create(
            name=job_name,
            tasks=tasks,
            schedule=CronSchedule(
                quartz_cron_expression=schedule,
                timezone_id="Asia/Tokyo",
            ),
        )

deploy.py (executed from CI/CD)

from job_deployer import deploy_jobs_by_schedule
from config.models import load_config

deploy_jobs_by_schedule(
    bronze_tables=load_config("config/bronze_config.yaml"),
    sg_tables=load_config("config/silver_gold_config.yaml"),
    bronze_notebook_path="/notebooks/bronze_task",
    sg_notebook_path="/notebooks/silver_gold_task",
)


Summary

Introducing config-driven pipelines resolves the problems raised at the start as follows.

The problem of similar code appearing everywhere Extract, Quality, and Load processing are implemented once in common modules. Even if the connection URL changes, only one file needs to be modified.

The problem of having to write a Python file every time a new table is added Just add one entry to the YAML. Review only needs to look at the configuration file, not Python code.

The problem of quality checks and error handling being implemented differently across each pipeline quality.py and loader.py are consolidated as common modules. Because they use the Strategy pattern, adding new checks or write patterns doesn't require touching existing code.

Here is a summary of where to make each type of change.

What you want to doWhere to change
Add a Bronze tableJust add one entry to bronze_config.yaml
Add a Silver/Gold tableJust add one entry to silver_gold_config.yaml
Join multiple sourcesAdd entries to the sources list and retrieve with dfs["alias"] in the transform
Add a new source typeAdd an Extractor to sources/ and register it in registry.py
Change transformation logicModify only the relevant file in transforms/
Change the load patternJust switch load_mode in the config
Add or change a quality checkJust edit checks in the config
Change behavior on check failureSwitch on_failure in the config between error / warn
Define dependencies between tablesJust edit depends_on in silver_gold_config.yaml
Change Bronze scheduleJust change schedule in bronze_config.yaml. Bronze with the same value is automatically grouped into the same Job
Run S/G after Bronze with a different scheduleJust add the Bronze table name to depends_on in silver_gold_config.yaml
ParallelizeExecute deploy.py from CI/CD to register the task graph with Databricks Jobs

Advanced: Making It More Robust

Once the core implementation is solid, adding the following two items can increase the reliability of the pipeline.

Validation with pydantic

In this article, configuration objects are defined with dataclass, but migrating to pydantic causes validation to run at YAML load time, detecting configuration errors before the pipeline crashes midway.

from pydantic import BaseModel, field_validator
from typing import Literal

class QualityConfig(BaseModel):
    on_failure: Literal["error", "warn"] = "error"  # ← anything other than error/warn is a validation error
    checks: list[QualityCheck] = []

class TableConfig(BaseModel):
    name: str
    destination: str
    load_mode: Literal["overwrite", "append", "merge", "overwrite_partition"] = "overwrite"  # ← typos caught here
    ...

    @field_validator("merge_keys")
    @classmethod
    def merge_keys_required_for_merge(cls, v, info):
        if info.data.get("load_mode") == "merge" and not v:
            raise ValueError("merge_keys must be specified when load_mode=merge")
        return v

def load_config(path: str) -> list[TableConfig]:
    import yaml
    with open(path) as f:
        raw = yaml.safe_load(f)
    return [TableConfig(**t) for t in raw["tables"]]  # ← validation runs here

By enumerating allowed values with Literal, typos like load_mode: mereg or invalid values like on_failure: stop are caught as errors at YAML load time. dacite is no longer needed, and pydantic handles both conversion and validation on its own.

Example of Adding a Check

Leveraging the extensibility of the Strategy pattern, you can add custom checks. For example, to add a NoDuplicatesChecker that checks "are there any duplicate rows?":

# 1. Add the class
class NoDuplicatesChecker:
    def check(self, df: DataFrame, rule: QualityCheck) -> CheckResult:
        total: int = df.count()
        distinct: int = df.select(rule.column).distinct().count()
        if total != distinct:
            return {"passed": False, "message": f"{rule.column} has {total - distinct} duplicate(s)"}
        return {"passed": True, "message": ""}

# 2. Add one line to CHECKERS
CHECKERS: dict[str, CheckerProtocol] = {
    "not_null": NotNullChecker(),
    "min_rows": MinRowsChecker(),
    "value_range": ValueRangeChecker(),
    "no_duplicates": NoDuplicatesChecker(),  # ← just add this
}

After adding, you can use it simply by writing type: no_duplicates in the checks section of your YAML.