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:
- 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.
- UoW is opt-in boilerplate. Cross-model mutations require a
manual
with UnitOfWork(ctx)wrapper. Forgetting it means partial writes on exception. - Event sourcing disconnected.
enable_event_sourcing()is global; there is no per-repository opt-in. - 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 | Nonefind_or_raise(ctx, iri)→T(raisesTrailsError)where(**filters)→QueryBuilderall(ctx)→list[T]count(ctx)→intexists(ctx, **filters)→boolsave(ctx, instance)→boolsave_many(ctx, instances)→intdelete(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 forUnitOfWork(ctx)- A clear home for cross-model business logic
- Composable: services can depend on other services
3. Design constraints¶
- Additive, not invasive.
Model,UnitOfWork, andEventStoreare unchanged. Repository wraps them; it does not subclass or monkey-patch. - Optional. Apps that prefer
Model.find/savedirectly continue to work. Repository is not required. - No magic. No auto-generated repositories from
@node_type. Users declare them explicitly. ARepositorybase class is sufficient — no metaclass, no descriptor magic. - 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 |