How to implement a custom adapter

delta-engine ships with two backends today, both for Delta Lake on Databricks: a Spark session backend and a Databricks SQL warehouse backend. A backend is defined by two port protocols — CatalogStateReader and PlanExecutor, in delta_engine.application.ports — and this guide shows how to implement them. See the hexagonal boundary for how the ports fit.

Implementing the two ports is enough for a backend that shares Delta’s semantics. A genuinely different table format needs more — see Adding a genuinely different backend below before you start.

The two protocols

Both live in delta_engine.application.ports. You do not inherit from them; you implement the required methods and the engine accepts your class.

CatalogStateReader

from delta_engine.application.ports import CatalogStateReader, CatalogState, ReadFailed, TableAbsent, TablePresent
from delta_engine.application.failures import ReadFailure
from delta_engine.domain.model import QualifiedName

class MyReader:
    def fetch_state(self, qualified_name: QualifiedName) -> CatalogState:
        try:
            # Return TableAbsent() if the table does not exist.
            # Return TablePresent(table=...) if it does.
            ...
        except Exception as exc:
            # MUST catch all exceptions and return ReadFailed — never raise.
            return ReadFailed(
                failure=ReadFailure(
                    exception_type=type(exc).__name__,
                    message=str(exc),
                ),
            )

fetch_state is total: it must never raise. Return ReadFailed for any error. A raised exception escapes the engine’s per-table error boundary and aborts the entire sync.

PlanExecutor

Execution is a two-stage boundary. compile turns a domain plan into the backend statements it lowers to, without touching the backend:

from delta_engine.application.ports import PlanExecutor, ExecutionSummary, ExecutionSucceeded, ExecutionFailed
from delta_engine.application.failures import ExecutionFailure
from delta_engine.domain.model import QualifiedName
from delta_engine.domain.plan.actions import ActionPlan

class MyExecutor:
    def compile(self, qualified_name: QualifiedName, plan: ActionPlan) -> tuple[str, ...]:
        return tuple(self._render(action) for action in plan.actions)

The engine calls compile only with the ActionPlan carried by a PlanningSucceeded result, on every dry or real run, and records the statements on the table’s report. A rejected diff has no plan and never reaches this port. compile is not total: compiling an accepted plan is a pure, local operation that cannot fail against a backend, so it may raise on a genuine programming error rather than swallowing it. The plan carries the relation kind its actions lower against (plan.kind) — backends whose DDL dialect differs by kind (Databricks streaming tables take ALTER STREAMING TABLE) read it off the plan; a backend with one dialect may ignore it.

execute then runs the statements compile produced — the engine passes the same tuple it recorded on the report, so what was previewed is exactly what runs. The statements are the complete unit of work; the table they target is already baked into each one by compile:

    def execute(self, statements: tuple[str, ...]) -> ExecutionSummary:
        results = []
        for i, statement in enumerate(statements):
            try:
                self._run(statement)
                results.append(ExecutionSucceeded(
                    statement_index=i,
                    statement=statement,
                ))
            except Exception as exc:
                results.append(ExecutionFailed(
                    failure=ExecutionFailure(
                        statement_index=i,
                        exception_type=type(exc).__name__,
                        message=str(exc),
                        statement=statement,
                    ),
                ))
                break  # stop at first failure — the engine is non-transactional
        return ExecutionSummary(results=tuple(results))

execute is total. Stop at the first failure and return the summary — the engine records partial results and moves on to the next table.

Wire the engine

Pass your implementations to Engine, then sync your declared tables through it. sync is variadic — pass one or more DeltaTable definitions directly:

from delta_engine import Engine

engine = Engine(reader=MyReader(), executor=MyExecutor())
engine.sync(customers, orders)  # your DeltaTable definitions

Compile actions to statements

plan.actions is a sorted tuple containing only Action objects. Each action has a TableAspect, a subject, and an ActionPhase; richer actions also keep the desired/observed semantic state while exposing stable compiler-facing properties such as SetColumnComment.column_name. You can inspect the action type with isinstance or match/case:

from delta_engine.domain.plan.actions import CreateTable, AddColumn, DropColumn

for action in plan.actions:
    match action:
        case CreateTable():
            ...
        case AddColumn():
            ...
        case DropColumn():
            ...

See delta_engine/adapters/databricks/sql/compile.py for a complete example using functools.singledispatch.

A backend that targets Databricks itself, over a SQL warehouse connection instead of a Spark session, does not need to reimplement the SQL layer: delta_engine.adapters.databricks.sql — the compiler, identifier quoting, and information_schema queries — is PySpark-free by contract and is shared between backends. delta_engine.adapters.databricks.warehouse is the live example: its reader and executor are the only backend-specific code it adds, built entirely on that shared core. The Spark backend in delta_engine.adapters.databricks.spark is the other consumer, adding its own reader, executor, and backend-specific error translation.

Adding a genuinely different backend

The two ports are import-clean: the domain and application layers never import pyspark or delta, and your adapter adds no backend imports to them. But import purity is not the whole story. The application layer still encodes Delta-specific policy in ordinary Python — DELTA_PROPERTY_POLICY in application/properties.py, and validation rules such as ColumnMappingRequiredForDrop that exist only because of how Delta handles column drops.

The consequence for a new backend:

  • A Delta-compatible backend — another Delta-on-Spark or Unity Catalog surface with the same property and safety semantics — can be added with the two ports alone.

  • A different table format, such as Iceberg, would first need that Delta-specific policy lifted out of the application layer (or made selectable per backend) so its own property model and safety rules could take its place. Implementing the ports is necessary but not sufficient.

See Import purity versus semantic coupling for the full picture.