Skip to content

ADR-0045: Facade, Proxy, and Unit of Work Patterns

  • Status: Accepted
  • Date: 2026-04-18
  • Relates to: ADR-0017 (ActiveGraph ORM), ADR-0019 (App Surface), ADR-0021 (Progressive Enhancement), ADR-0023 (SPARQL Federation)

Context

Trails has a rich set of modules — decorators, ORM, shapes, runtime, HTTP adapter, federation — but wiring them together requires touching 5+ imports and understanding internal sequencing. New users face a "wall of imports" before they can run a hello-world. Three well-known design patterns address distinct friction points:

  1. Facade (App class). Users must import and compose @capability, @node_type, @shape, Context, create_http_app separately. A single App entry point hides this wiring.

  2. Proxy (FederationPeer). The federation surface currently connects to remote peers eagerly when configured. Peers that are down at startup cause errors even if no federated query is ever issued. A lazy proxy delays connection and schema fetching until first use.

  3. Unit of Work. ctx.kg.transaction() already buffers SPARQL writes at the KG level. A higher-level UnitOfWork operates on the Context level: it stages model saves and deletes, then commits them as a single SPARQL UPDATE. This gives application code a cleaner transaction boundary that works with ORM instances rather than raw SPARQL.

Decision

Facade — trails.app.App

  • App(name, version=, base_iri=) — single constructor.
  • @app.model(name, fields=) — registers a @node_type.
  • @app.capability(name, description=) — registers a @capability.
  • @app.shape(name, predicates=) — registers a @shape.
  • app.run(host=, port=) — starts the HTTP adapter via uvicorn.
  • app.context() — returns a fresh Context.

App delegates entirely to existing modules. It adds zero new functionality — it is a pure Facade over trails.decorators, trails.orm, trails.shapes, trails.runtime, and trails.http_adapter.

Proxy — trails.federation_proxy.FederationPeer

  • FederationPeer(name, url, mcp_url=) — stores config, does NOT connect.
  • .schema property — fetches and caches SchemaAdvertisement on first access via fetch_peer_schema().
  • .healthy property — probes health on first access, caches result.
  • .query(sparql, trace_id=) — executes a federated SPARQL query.
  • .invoke(capability_id, arguments) — invokes a remote capability via MCP.
  • .refresh() — clears cached schema and health for re-probing.
  • repr shows status: lazy, connected, or unreachable.

Unit of Work — trails.uow.UnitOfWork

  • UnitOfWork(ctx) — wraps a Context.
  • .save(instance) / .delete(instance) — stage operations.
  • .execute_sparql(sparql) — stage a raw SPARQL UPDATE.
  • .commit() — execute all pending operations in one SPARQL UPDATE.
  • .rollback() — discard all pending operations.
  • Context manager: commits on clean exit, rolls back on exception.

This is intentionally distinct from ctx.kg.transaction() which operates at the KG/store level. UnitOfWork is the application-level pattern that collects model mutations and lowers them to a single KG transaction at commit time.

Consequences

  • Positive: Dramatically simpler onboarding — from trails import App is all a new user needs. Federation peers are safe to configure even when peers are offline. Application code can batch complex multi-model mutations atomically.
  • Negative: One more layer of indirection. Users who outgrow App must learn the underlying modules. UnitOfWork adds a second transaction surface alongside ctx.kg.transaction() — docs must clarify when to use which.
  • Neutral: All three patterns are opt-in. Existing code continues to work unchanged.