Skip to content

14. Resolver → Service → Repository Architecture

Date: 2026-04-15

Status

Proposed

Context

The existing GraphQL backend follows a pattern where resolvers and services are coupled to their data source. Services extend TenantBaseService, which provides both business logic orchestration and direct Prisma data access in a single class. This creates several problems:

  • Mixed concerns: business rules, data access, and side effects live in the same layer
  • Untestable services: testing business logic requires mocking Prisma internals
  • Storage coupling: services know they’re talking to Postgres (via Prisma) — switching data sources or adding caching requires changes to business logic
  • Fat resolvers: some resolvers contain 100-200 lines mixing validation, data fetching, side effects, and error handling
  • Untyped errors: mutations throw errors into the GraphQL errors array as untyped strings — clients must parse messages to handle different failure cases

Additionally, TenantBaseService is effectively a repository base class (CRUD + RLS + tenant scoping) that was named “Service” because the codebase didn’t have a repository layer.

Related: ADR-0011 (Service Dependency Injection) established constructor injection for service-to-service dependencies. This ADR extends that pattern to introduce a repository layer and formalize the full architecture.

Decision

Adopt a strict three-layer architecture for all new features and gradual migration of existing code:

Resolver → Service → Repository → Database

Layer responsibilities

Resolver — transport only.

  • Extracts args from the GraphQL operation
  • Calls a single service method
  • Maps the result to a GraphQL return type (union members for mutations)
  • Catches typed business errors and returns them as union type members
  • Rethrows unexpected errors (the existing useSentry envelop plugin handles those)
  • Maximum ~10 lines

Service — application logic.

  • Plain class with constructor-injected dependencies (no TenantBaseService inheritance)
  • Owns business rules, validation, and orchestration
  • Owns side effect coordination (e.g. postCreate / postUpdate)
  • Never contains raw database queries, FQL, or Prisma calls
  • Never imports Sentry (the plugin handles unexpected errors at the boundary)

Repository — data access.

  • Extends TenantBaseService for CRUD + RLS + tenant scoping
  • Exposes domain-specific query methods (e.g. listByVehicle, findMatchingDate)
  • Hides all storage implementation — the service calls repository methods, never raw queries
  • Feature flag branching for dual-write migration lives exclusively here (future)
  • Lives in the top-level repositories/ directory, sibling to services/

Union return types on mutations

Mutations use union return types to make success and failure explicit in the schema:

union CreateFeatureResult =
CreateFeatureSuccess |
FeatureValidationError
type Mutation {
createFeature(input: CreateFeatureInput!): CreateFeatureResult!
}

The resolver maps service outcomes to union members:

try {
const result = await context.services.featureService.create(input);
return { __typename: 'CreateFeatureSuccess', result };
} catch (error) {
if (error instanceof ApiError) {
return { __typename: 'FeatureValidationError', message: error.message, code: error.code };
}
throw error; // unexpected — Sentry plugin handles it
}

This coexists with existing Relay Edge/Connection patterns. Queries continue to use Connection types for pagination.

Dependency injection and context wiring

  • Repositories are instantiated in createTenantServices (or createPostgresOnlyTenantServices)
  • Repositories are injected into services via constructor
  • Services are exposed on the GraphQL context
  • Resolvers access services via context.services

TenantBaseService as repository base

TenantBaseService provides exactly what a repository needs: type-safe Prisma CRUD, RLS via Prisma extensions, and automatic tenant scoping. Rather than creating a new TenantBaseRepository base class, repositories extend TenantBaseService directly. If this architecture is approved, TenantBaseService can be renamed to TenantBaseRepository and existing services can gradually adopt the new pattern.

Proof of Concept

The vehicle location calendar feature (TOP-4832) implements this architecture:

  • Repository: repositories/vehicle-location-calendar/VehicleLocationCalendarRepository.ts

    • Extends TenantBaseService for CRUD + RLS
    • Adds domain methods: listByVehicle(), findMatchingDate()
    • Encapsulates all Prisma query logic (date overlap filters, pagination)
  • Service: services/tenant/vehicle-location-calendar/VehicleLocationCalendarService.ts

    • Plain class, no TenantBaseService inheritance
    • Receives repository via constructor injection
    • Owns validation (dateFrom <= dateTo), hierarchical date resolution logic
    • 13 unit tests with mocked repository — demonstrates testability
  • Resolvers: server/graphql/resolvers/{query,mutation}/vehicleLocationCalendar/

    • TypeScript, ~10 lines each
    • Mutations return union types (CreateCalendarEntrySuccess | CalendarEntryValidationError)
    • Query returns Relay Connection type
  • Schema: server/graphql/schema/vehicleLocationCalendar.graphql

    • Union return types for all mutations
    • Connection type for the list query
    • Reuses existing Address/AddressInput types

Consequences

Benefits

  • Testable services: business logic tested with mocked repositories, no database needed
  • Separation of concerns: each layer does exactly one thing
  • Typed errors: clients know every possible mutation outcome at compile time
  • Portable services: services have no Prisma knowledge — data source can change without touching business logic
  • Incremental adoption: new features use this architecture; existing code migrates gradually

Risks & Mitigations

  • Two patterns coexist: old (fat resolvers + service-as-repository) and new (layered architecture)
    • Mitigation: new features follow the new pattern; existing code migrates as it’s touched
  • Repository naming: repositories extend TenantBaseService (named “Service”)
    • Mitigation: rename to TenantBaseRepository once the pattern is validated
  • Union type adoption: frontend must handle union fragments on mutations
    • Mitigation: only new mutations use unions; existing mutations unchanged

References

  • docs/development/architecture/graphql-architecture-context.md — full architecture specification
  • ADR-0011 — Service Dependency Injection (predecessor)
  • TOP-4832 — PoC implementation (vehicle location calendar)