Skip to content

ADR-0063: Repository and Service Patterns

  • Status: Accepted
  • Date: 2026-04-18
  • Depends on: ADR-0017 (ORM), ADR-0045 (UoW / Pipeline / Facade), ADR-0058 (Lifecycle Hooks), ADR-0061 (Event Sourcing)
  • Target milestone: Post-M23

Context

Trails ships a rich set of primitives — ORM (Model), Unit of Work, Event Store, lifecycle hooks, and validation (SHACL + shapes). Today every @capability handler manually wires these together:

@capability("publish_post")
def publish_post(ctx, post_iri: str) -> dict:
    post = Post.find(ctx, post_iri)
    if not post:
        raise TrailsError("not found")
    post.published = True
    post.save(ctx)
    notif = Notification(type="published", target=post.iri)
    notif.save(ctx)
    return {"id": post.iri}

Problems:

  1. No typed data-access layer. Queries live inline in handlers. Custom queries (e.g. "all published posts") are re-implemented wherever needed instead of living in a reusable, tested layer.
  2. UoW is opt-in boilerplate. Cross-model mutations require a manual with UnitOfWork(ctx) wrapper. Forgetting it means partial writes on exception.
  3. Event sourcing disconnected. enable_event_sourcing() is global; there is no per-repository opt-in.
  4. No domain service pattern. Multi-model business operations have no standard home — they scatter across capability handlers.

Rails solved this with ActiveRecord + concerns + service objects. Django solved it with managers + forms + views. Trails needs an analogous mid-level abstraction.

Decision

1. Repository[T] — typed data-access facade per model

from trails.repository import Repository

class PostRepo(Repository["Post"]):
    def published(self, ctx) -> list:
        return self.where(published=True).fetch(ctx)

    def by_author(self, ctx, author_iri: str) -> list:
        return self.where(author=author_iri).fetch(ctx)

Repository composes:

Concern How
ORM queries Delegates to Model.find, Model.where, Model.annotate
UnitOfWork repo.save() and repo.delete() auto-stage into a UoW when one is active
Event sourcing Optional events=True kwarg emits events on mutation
Validation Runs instance.errors() before save (configurable)

Built-in methods (inherited, not generated):

  • find(ctx, iri)T | None
  • find_or_raise(ctx, iri)T (raises TrailsError)
  • where(**filters)QueryBuilder
  • all(ctx)list[T]
  • count(ctx)int
  • exists(ctx, **filters)bool
  • save(ctx, instance)bool
  • save_many(ctx, instances)int
  • delete(ctx, instance)None

2. Service — orchestration of cross-model logic

from trails.service import Service

class PublishService(Service):
    def __init__(self, posts: PostRepo, notifications: NotificationRepo):
        super().__init__()
        self.posts = posts
        self.notifications = notifications

    def publish(self, ctx, post_iri: str) -> "Post":
        with self.unit_of_work(ctx) as uow:
            post = self.posts.find_or_raise(ctx, post_iri)
            post.published = True
            self.posts.save(ctx, post, uow=uow)
            notif = Notification(type="published", target=post.iri)
            self.notifications.save(ctx, notif, uow=uow)
        return post

Service provides:

  • unit_of_work(ctx) — convenience wrapper for UnitOfWork(ctx)
  • A clear home for cross-model business logic
  • Composable: services can depend on other services

3. Design constraints

  1. Additive, not invasive. Model, UnitOfWork, and EventStore are unchanged. Repository wraps them; it does not subclass or monkey-patch.
  2. Optional. Apps that prefer Model.find/save directly continue to work. Repository is not required.
  3. No magic. No auto-generated repositories from @node_type. Users declare them explicitly. A Repository base class is sufficient — no metaclass, no descriptor magic.
  4. Zero new dependencies. Pure stdlib + existing trails imports.

Consequences

  • Capability handlers shrink to thin dispatch-to-service calls.
  • Custom queries are tested in isolation (repository unit tests).
  • UoW usage becomes natural (services wire it; repos respect it).
  • Event sourcing can be opted in per-repository.
  • Future CQRS split becomes straightforward: read-repos vs write-repos.

Alternatives Considered

Alternative Reason for rejection
Auto-generated repos from @node_type Magic; harder to test; less explicit
Full CQRS (Command/Query split) Too heavy for alpha; Repository gives 80% of the value
Django-style Manager on Model class Mixes data access into the model; violates SRP
ActiveRecord callbacks only Already have hooks (ADR-0058); need a composition layer, not more hooks