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
errorsarray 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 → DatabaseLayer 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
useSentryenvelop plugin handles those) - Maximum ~10 lines
Service — application logic.
- Plain class with constructor-injected dependencies (no
TenantBaseServiceinheritance) - 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
TenantBaseServicefor 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 toservices/
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(orcreatePostgresOnlyTenantServices) - 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
TenantBaseServicefor CRUD + RLS - Adds domain methods:
listByVehicle(),findMatchingDate() - Encapsulates all Prisma query logic (date overlap filters, pagination)
- Extends
-
Service:
services/tenant/vehicle-location-calendar/VehicleLocationCalendarService.ts- Plain class, no
TenantBaseServiceinheritance - Receives repository via constructor injection
- Owns validation (
dateFrom <= dateTo), hierarchical date resolution logic - 13 unit tests with mocked repository — demonstrates testability
- Plain class, no
-
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/AddressInputtypes
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
TenantBaseRepositoryonce the pattern is validated
- Mitigation: rename to
- 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)