Skip to content

Latest commit

 

History

History
84 lines (60 loc) · 6.65 KB

File metadata and controls

84 lines (60 loc) · 6.65 KB

Pattern: Adapter SPI for target-specific behaviour

Status: Stable Maintainer: architect

Intent

Suppose Postgres needs to emit a RETURNING clause and MySQL doesn't, and the framework code that builds an INSERT plan needs to know which to do. That code can't ask if (target === 'postgres') — the framework is target-agnostic by design. The decision has to live somewhere target-specific, and the adapter is where it lives: each target ships an object the framework calls into for anything dialect-shaped — lowering, capability discovery, error mapping, marker reading.

The pattern: declare an Adapter interface that the framework consumes uniformly, ship one implementation per target, and route every target-specific call through that interface. The framework never asks which target it is talking to.

When to use

  • Framework code needs target-specific behaviour at runtime — lowering an AST, rendering a dialect, mapping an error, checking a capability, reading the contract marker.
  • The set of targets is open: more targets land over time and the framework cannot enumerate them at compile time.
  • The variation is per target, not per IR kind. (Per-IR-kind variation belongs in Three-layer polymorphic IR; per-layer dispatch is SPI at the lowest consuming layer.)

When NOT to use

  • Code that is genuinely target-agnostic. Don't introduce an adapter call that always returns the same answer regardless of target; the adapter exists to capture variation, and flat code paths should stay flat.
  • Variation expressible as abstract methods on a class hierarchy the framework already owns — use Three-layer polymorphic IR. The adapter is for behaviour the framework cannot model itself; if it can model it, it should.
  • One-off branches inside a single subsystem with no plausible second adopter. An adapter SPI for a single permanent target is a misnamed concrete type.

Structure

// framework-defined adapter interface (one adapter per target)
export interface Adapter<Ast, TContract, TBody> {
  readonly profile: AdapterProfile;
  lower(ast: Ast, context: LowererContext<TContract>): TBody;
}

export interface AdapterProfile<TTarget extends string = string> {
  readonly id: string;
  readonly target: TTarget;
  readonly capabilities: Record<string, unknown>;
  readMarkerStatement(): MarkerStatement;
  parseMarkerRow(row: unknown): ContractMarkerRecord;
}

// per-target implementer (target package)
class PostgresAdapterImpl implements Adapter<AnyQueryAst, PostgresContract, PostgresLoweredStatement> {
  // …per-target lowering, marker SQL, capability profile…
}

export function createPostgresAdapter(options?: PostgresAdapterOptions) {
  return Object.freeze(new PostgresAdapterImpl(options));
}

Framework code that needs target-specific behaviour calls a method on the Adapter (adapter.lower(ast, ctx), adapter.profile.readMarkerStatement(), adapter.profile.capabilities[…]). It never asks "is this Postgres?". The adapter is exposed via Interface + factory function, so the implementation class stays private.

Reference implementations

Implementation Path Demonstrates
Framework Adapter interface packages/2-sql/4-lanes/relational-core/src/ast/adapter-types.ts The SQL family's adapter interface; the framework's only contract for SQL targets.
PostgresAdapter (canonical example) packages/3-targets/6-adapters/postgres/src/core/adapter.ts The Postgres implementation; private PostgresAdapterImpl, factory-exposed; capability profile, marker SQL, and lowering all live here.
Visitor-as-SPI variant per ADR 198 The runtime layer applies the same shape as a visitor SPI: the runner depends on a visitor interface and the driver implements it.

Related ADRs

Related patterns

Related rules

  • .cursor/rules/no-target-branches.mdc — the tactical enforcement: never branch on target string in core code; route the variation through an adapter call. The pattern entry is the structural rationale this rule encodes.

Cautions / common mistakes

  • Smuggling a target check past the adapter. Code that does adapter.profile.target === 'postgres' defeats the pattern — it is if-target with an extra hop. The framework consumes adapter methods, not adapter identity.
  • Adapter interface that grows by accretion. Every new framework caller adds a method, the interface bloats, and adapters become hard to implement. Surface as debt and decompose; consider whether the new caller is really framework code or really subsystem code.
  • One-target adapter SPIs. An adapter interface with a single permanent implementer is over-abstracted; collapse to a concrete type until the second target exists.
  • Branching inside the adapter on the target the adapter is for. A PostgresAdapter does not need to ask "is this Postgres?" — it is Postgres. If it asks, the wrong abstraction is in play.