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.errors import ReadError
from delta_engine.application.ports import CatalogStateReader, CatalogState, TableAbsent, TablePresent
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:
# Translate backend-specific failures into the port's error.
raise ReadError(
exception_type=type(exc).__name__,
message=str(exc),
) from exc
Returning normally means the state is known: the table is either present or
absent. Raise ReadError when the adapter cannot determine either state. The
engine catches that specific error for the current table, records a
ReadFailure, and continues with independent tables. Backend-specific and
unexpected exceptions are outside the port contract and propagate.
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.errors import ExecutionError
from delta_engine.application.ports import CompiledPlan, PlanExecutor
from delta_engine.domain.plan import ActionPlan
class MyExecutor:
def compile(self, plan: ActionPlan) -> CompiledPlan:
return CompiledPlan(
plan=plan,
statements=tuple(
self._render(plan.target, plan.kind, action) for action in plan.actions
),
)
The engine calls compile only with the ActionPlan carried by a
PlanningAccepted result, on every dry or real run, and records the
compiled plan on the table’s report. The pairing is positional —
statements[i] is the single statement that applies plan.actions[i] —
and CompiledPlan rejects a missing, extra, or blank statement. 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
both the qualified table target (plan.target) and the relation kind its
actions lower against (plan.kind). Backends whose DDL dialect differs by
kind read both facts from the plan; a backend with one dialect may ignore the
kind. Databricks, for example, uses ALTER STREAMING TABLE for annotation
actions on a streaming table. Eligibility validation ensures that wider
streaming-table declarations never produce an accepted plan.
The engine passes those same compiled statements to execute one at a time.
The table they target is already baked into each statement. Returning normally
means the statement succeeded; translate an expected backend failure into
ExecutionError:
def execute(self, statement: str) -> None:
try:
self._run(statement)
except Exception as exc:
raise ExecutionError(
exception_type=type(exc).__name__,
message=str(exc),
) from exc
The application owns statement indexes, constructs the ExecutionResult, and
stops after the first ExecutionError. It then records the partial result and
moves on to the next independent table. Exceptions other than
ExecutionError are port-contract or programming errors and propagate.
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 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, executor, and connection runner are the only
backend-specific code it adds, built on that shared core. Error translation is
shared too, one level up in delta_engine.adapters.databricks: both backends’
readers go through read_catalog_state and both executors through
execute_statement. The Spark backend in
delta_engine.adapters.databricks.spark is the other consumer, in the same
shape: its own reader, executor, and Spark-session runner.
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.