ADR-003: GraphQL Datasource Pattern (Convertor)
The Convertor pattern provides chainable, composable data pipelines for handling GraphQL operations, cache management, and subscriptions using Ferry.
This ADR was originally written for the Strategy/DatasourceModule pattern. It has been amended to reflect the Convertor pattern, which replaces Strategy entirely. See ADR-005 for the migration rationale.
1. History (The Problem Context)
Section titled “1. History (The Problem Context)”The Problem
Section titled “The Problem”Ferry’s official documentation provides extremely trivial examples for mutations and cache handling that create circular dependencies when using Riverpod dependency injection with real-world state requirements.
Context
Section titled “Context”- When: Implementing GraphQL operations with Ferry and Riverpod
- Where: Chat systems, attendee management, and other real-time features
- Who: Flutter team using Riverpod for dependency injection
- Constraints:
- Circular Dependency Problem: Ferry’s example creates
client -> cache handlers -> requests -> clientdependency cycle - State-Dependent Requests: Real-world scenarios require passing dynamic state to requests
- APIs return ambiguous data (e.g., chat API returns
user1/user2instead of clear current/other user) - Subscriptions need context filtering (only show messages for current chat and user)
- Circular Dependency Problem: Ferry’s example creates
Previous Approaches
Section titled “Previous Approaches”Strategy/DatasourceModule Pattern (Retired):
The original solution used a Strategy pattern with RequestStrategy, CacheHandlerStrategy, RequestContext, and DatasourceModule classes. While this solved the circular dependency problem, it introduced excessive boilerplate, required manual strategy registration, and made simple operations unnecessarily complex.
Convertor Pattern (Current): The Convertor pattern replaces Strategy with a simpler, more composable approach based on functional pipelines.
2. Reason (Architecture Decision Record)
Section titled “2. Reason (Architecture Decision Record)”Decision
Section titled “Decision”Implement a Convertor-based GraphQL Datasource with:
- Convertors for building request parameters into Ferry operation requests
- GraphQLRequestExecutor for executing operations via the Ferry client
- GraphQLStreamConvertor for unwrapping Ferry responses into clean data streams
- CacheHandlerSpecs + UpdateCacheTypedLink for declarative cache management
- Chain, Interceptor, Decorator for composing pipelines
Why Convertor Over Strategy
Section titled “Why Convertor Over Strategy”| Concern | Strategy Pattern | Convertor Pattern |
|---|---|---|
| Boilerplate | High (strategy classes, keys, module registration) | Low (functional composition) |
| Composability | Limited (fixed strategy slots) | High (chainable pipelines) |
| Type safety | Good | Excellent (covariant input types) |
| Cache handling | Manual handler registration | Declarative specs with UpdateCacheTypedLink |
| Learning curve | Strategy pattern + custom abstractions | Simple input/output transformation |
| Testing | Mock strategies and contexts | Test individual convertors in isolation |
Rationale
Section titled “Rationale”The Convertor pattern allows us to:
- Eliminate circular dependencies through composable pipelines that don’t require central registration
- Chain transformations fluently:
executor.then(streamConvertor).map(extract).thenMap(modelConvertor) - Handle context-dependent operations through convertor input parameters
- Maintain type safety with covariant generic types throughout the chain
- Manage cache declaratively with
CacheHandlerSpecsinstead of imperative handler registration
Trade-offs
Section titled “Trade-offs”- Benefits:
- Dramatically reduced boilerplate vs Strategy pattern
- Composable, testable, reusable pipeline components
- Declarative cache management with UpdateCacheTypedLink
- Works naturally with Ferry’s stream-based architecture
- Costs:
- Requires understanding functional composition
- Complex cache scenarios still require careful spec configuration
- Risks:
- Deeply nested chains can be harder to debug (mitigated by interceptors)
3. Implementation (Code Snippets & Walkthrough)
Section titled “3. Implementation (Code Snippets & Walkthrough)”Core Architecture
Section titled “Core Architecture”The Convertor-based datasource consists of:
- Request Convertors - Transform typed parameters into Ferry operation requests
- GraphQLRequestExecutor - Execute operations via Ferry client
- GraphQLStreamConvertor - Unwrap response streams into data streams
- Model Convertors - Transform GQL raw types into domain models
- CacheHandlerSpecs - Declarative cache update configuration
Step 1: Request Convertor
Section titled “Step 1: Request Convertor”A Convertor that builds a Ferry request from typed parameters:
@riverpodConvertor<GQueryNotificationReq, ListRequestParams<GnotificationFilters, GnotificationOrder>>notificationListQueryConvertor(Ref ref) { return Convertor((params) { return GQueryNotificationReq((builder) { builder ..requestId = 'notification_list_${params.toString()}' ..vars.G_filter = params.filter?.toBuilder() ..vars.G_sort = params.sort?.toBuilder() ..vars.G_skip = params.skip ..vars.G_take = params.take; }); });}Step 2: Datasource Pipeline
Section titled “Step 2: Datasource Pipeline”Chain the executor with stream conversion and data extraction:
@riverpodStreamConvertor<List<GQueryNotificationData_notification_notificationItems>, ListRequestParams<GnotificationFilters, GnotificationOrder>>notificationListDatasource(Ref ref) { return GraphQLRequestExecutor< GQueryNotificationData, ListRequestParams<GnotificationFilters, GnotificationOrder>, GQueryNotificationVars>( gqlClient: ref.watch(gqlClientProvider), convertor: ref.watch(notificationListQueryConvertorProvider), ) .then(GraphQLStreamConvertor()) .map((data) => data.notification!.notificationItems!.nonNulls.toList());}Step 3: Repository with Model Conversion
Section titled “Step 3: Repository with Model Conversion”The repository applies domain model conversion:
class NotificationsRepository { const NotificationsRepository({ required this.listDatasource, required this.modelConvertor, required this.filterConvertor, required this.sortConvertor, });
final StreamConvertor< List<GQueryNotificationData_notification_notificationItems>, ListRequestParams<GnotificationFilters, GnotificationOrder> > listDatasource;
final Convertor<NotificationModel, GQueryNotificationData_notification_notificationItems> modelConvertor; final Convertor<GnotificationFilters, NotificationFilterCriteria> filterConvertor; final Convertor<GnotificationOrder, NotificationSortCriteria> sortConvertor;
Stream<List<NotificationModel>> queryList({ int? skip, int? take, NotificationFilterCriteria? filter, NotificationSortCriteria? sort, }) { final params = ListRequestParams<GnotificationFilters, GnotificationOrder>( skip: skip, take: take, filter: filterConvertor.mightExecute(filter), sort: sortConvertor.mightExecute(sort), );
return listDatasource.thenEach(modelConvertor).execute(params); }}Step 4: Cache Management with CacheHandlerSpecs
Section titled “Step 4: Cache Management with CacheHandlerSpecs”Declarative cache configuration replaces manual handler registration:
// Clear cache for a specific requestCacheHandlerSpecs.clear( mapToCachedRequest: Convertor((request) { return GQueryNotificationReq((b) => b ..requestId = 'notification_list' ..vars = request.vars.toBuilder()); }),);
// Merge mutation response into cached queryCacheHandlerSpecs.merge( mapToCachedRequest: Convertor((request) => /* map to cached query request */), mapResponse: Convertor((mutationData) => /* extract data to merge */), mergeCachedData: Convertor(((oldData, newData)) => /* merge logic */),);Step 5: Mutation with Optimistic Updates (Tuple Input)
Section titled “Step 5: Mutation with Optimistic Updates (Tuple Input)”Mutations use a tuple (Params, bool) to optionally enable optimistic responses:
@riverpodConvertor<GMutateChatSaveReq, (UpsertRequestParams<GMutateChatSaveVars>, bool)>chatUpsertConvertor(Ref ref) { return Convertor((params) { return GMutateChatSaveReq((builder) { builder ..requestId = 'chat_upsert_${params.$1.toString()}' ..vars = params.$1.vars.toBuilder(); if (params.$2) { builder ..optimisticResponse.chatSave.id = '__optimistic__' ..optimisticResponse.chatSave.isBlockedByUser1 = params.$1.vars.chatArg?.isBlockedByUser1; } }); });}Key Patterns
Section titled “Key Patterns”- Use
@riverpodfor all providers - Codegen is the canonical Riverpod pattern - Chain with
.then(),.map(),.thenMap(),.thenEach()for composable pipelines - Use
CacheHandlerSpecsfor declarative cache management (clear, clearAll, merge) - Repository cleans data - Transform GQL raw models to domain models in the repository
- Interceptors for side effects - Logging, analytics, error tracking without modifying data flow
4. Impact & Consequences
Section titled “4. Impact & Consequences”Positive Consequences
Section titled “Positive Consequences”- Eliminated circular dependencies through composable functional pipelines
- Dramatically reduced boilerplate compared to Strategy/DatasourceModule pattern
- Declarative cache management with CacheHandlerSpecs + UpdateCacheTypedLink
- Type-safe composable chains with covariant generics
- Easy to test - each Convertor is a pure function testable in isolation
- Shared Dart package - Convertor pattern is reusable across projects
Negative Consequences
Section titled “Negative Consequences”- Functional composition learning curve for developers unfamiliar with the paradigm
- Complex cache specs still require careful configuration for advanced scenarios
Monitoring & Success Metrics
Section titled “Monitoring & Success Metrics”- Reduced lines of code per feature datasource implementation
- Faster feature development with composable building blocks
- Fewer cache-related bugs with declarative spec-based approach
- Team adoption rate of the Convertor pattern across all projects