ADR-003: GraphQL Datasource Pattern
A sophisticated pattern for handling GraphQL operations, cache management, and context-aware subscriptions using Ferry while avoiding circular dependencies.
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 (can’t use global definitions)
- 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 Approach
Section titled “Previous Approach”Following Ferry’s documentation directly led to circular dependencies with Riverpod:
// Ferry's example approach - creates circular dependency@riverpodClient client(ClientRef ref) { return Client( link: Link.from([ // Cache handlers need requests, but requests need client UpdateCacheLink(handlers: ref.watch(cacheHandlersProvider)), // ❌ Circular! HttpLink('https://api.example.com/graphql'), ]), );}
@riverpodMap<String, UpdateCacheHandler> cacheHandlers(CacheHandlersRef ref) { return { 'chatSave': (proxy, response) { // Need to create request here, but request needs client final request = GQueryChatReq(...); // ❌ Where does client come from? proxy.writeQuery(request, ...); } };}The Core Problem: Ferry’s example assumes global definitions, but real-world scenarios require:
- Dynamic state passed to requests (filters, user context, etc.)
- Proper dependency injection with Riverpod
- Context-dependent cache operations
2. Reason (Architecture Decision Record)
Section titled “2. Reason (Architecture Decision Record)”Decision
Section titled “Decision”Implement a Strategy Pattern-based GraphQL Datasource with:
- Request Strategies for building different types of operations
- Cache Handler Strategies for managing optimistic updates
- Request Context for dependency injection and state management
- Context-aware subscriptions for filtering real-time data
Alternatives Considered
Section titled “Alternatives Considered”-
Direct Ferry Usage
- Pros: Simple, follows official docs
- Cons: Circular dependencies, repetitive code, no context handling
-
Repository Pattern Only
- Pros: Clean abstraction
- Cons: Still has DI issues, doesn’t solve Ferry-specific problems
-
Custom GraphQL Client
- Pros: Full control
- Cons: Reinventing the wheel, losing Ferry’s benefits
-
Chosen: Strategy Pattern with Context ✅
- Pros: Solves DI issues, reusable, context-aware, type-safe
- Cons: More complex initially, requires understanding of pattern
Rationale
Section titled “Rationale”The Strategy pattern allows us to:
- Break circular dependencies by separating request building from datasource logic
- Reuse request logic across different contexts
- Handle context-dependent operations like chat subscriptions
- Maintain type safety throughout the entire chain
- Support Ferry’s advanced features like optimistic updates and cache management
Trade-offs
Section titled “Trade-offs”- Benefits:
- Eliminates circular dependencies
- Highly reusable and type-safe
- Supports complex context-aware operations
- Integrates well with Ferry’s advanced features
- Costs:
- Higher initial complexity
- More files and abstractions to maintain
- Requires team understanding of Strategy pattern
- Risks:
- Over-engineering for simple use cases
- Potential performance overhead from abstraction layers
3. Implementation (Code Snippets & Walkthrough)
Section titled “3. Implementation (Code Snippets & Walkthrough)”Core Architecture
Section titled “Core Architecture”The pattern consists of four main components:
- Base Datasource Interfaces - Define contracts for different operation types
- Request Strategies - Handle building specific GraphQL requests
- Cache Handler Strategies - Manage optimistic updates and cache operations
- Request Context - Coordinate strategies and manage dependencies
Step 1: Base Datasource Interfaces
Section titled “Step 1: Base Datasource Interfaces”abstract class GqlDatasource { final GqlClient client; final RequestContext requestContext;
GqlDatasource({required this.client, required this.requestContext});}
abstract class QueryableDatasource<QueryReq, QueryRes, QueryFilters, QueryOrder> extends GqlDatasource { Stream<QueryRes> queryList({ int? skip, int? take, QueryFilters? filter, QueryOrder? sort, });
Stream<QueryRes> queryOne({QueryFilters? filter});}
abstract class SubscribableDatasource< SubscriptionReq, SubscriptionRes, SubscriptionContext> extends GqlDatasource { Stream<SubscriptionRes> subscribe({SubscriptionContext? context});}Step 2: Request Strategies
Section titled “Step 2: Request Strategies”Request strategies handle the building of GraphQL requests with proper typing:
abstract class ListItemsRequestStrategy<R, F, O> implements RequestStrategy<R, ListRequestParams<F, O>>, RequestHolder<R> { String get baseRequestId;
@override String get requestId => "${baseRequestId}_list";
R createRequest(ListRequestParams<F, O> params);
@override R build(ListRequestParams<F, O> params);}
// Concrete implementation exampleclass ChatListRequestStrategy implements ListItemsRequestStrategy<GQueryChatReq, GchatFilters, GchatOrder> { @override final String baseRequestId = 'chat';
@override GQueryChatReq createRequest(ListRequestParams<GchatFilters, GchatOrder> params) { return GQueryChatReq((b) => b ..requestId = requestId ..vars.G_skip = params.skip ..vars.G_take = params.take ..vars.G_filter = params.filter?.toBuilder() ..vars.G_sort = params.sort?.toBuilder()); }
GQueryChatReq? _request;
@override GQueryChatReq? get request => _request;
@override GQueryChatReq build(ListRequestParams<GchatFilters, GchatOrder> params) { _request = createRequest(params); return _request!; }
@override String get key => ChatRequestStrategyKeys.chatList.name;}Step 3: Context-Aware Subscriptions
Section titled “Step 3: Context-Aware Subscriptions”For handling complex subscription filtering (like chat messages):
class ChatMessageSubscriptionRequestStrategy implements SubscriptionRequestStrategy< GSubscribeToChatMessagesReq, ChatMessageSubscriptionContext > {
@override GSubscribeToChatMessagesReq createRequest( SubscriptionRequestParams<ChatMessageSubscriptionContext> params, ) { return GSubscribeToChatMessagesReq((b) => b ..requestId = requestId ..updateCacheHandlerKey = ChatMessageCacheHandlerStrategyKeys .messageSubscriptionCacheHandler.name ..updateCacheHandlerContext = params.context?.toJson()); }}Step 4: Context-Aware Cache Handlers
Section titled “Step 4: Context-Aware Cache Handlers”Cache handlers can access subscription context for intelligent filtering:
class ChatMessageSubscriptionCacheHandlerStrategy implements CacheHandlerStrategy< GSubscribeToChatMessagesData, GSubscribeToChatMessagesVars > {
@override UpdateCacheHandler build(RequestContext requestContext) { return (proxy, response) { final subscriptionItem = response.data?.messageSubscription; final context = response.operationRequest.updateCacheHandlerContext;
if (context == null) return;
// Parse context for filtering final subscriptionContext = ChatMessageSubscriptionContext.fromJson(context);
// Skip if message is not for current chat or from current user if (subscriptionContext.chatId != subscriptionItem?.systemParentId || subscriptionContext.currentUserId == subscriptionItem?.senderID) { return; }
// Update cache only for relevant messages final filter = GmessageFilters((b) => b..systemParentId = subscriptionItem.systemParentId); // ... cache update logic }; }}Step 5: DatasourceModule Pattern
Section titled “Step 5: DatasourceModule Pattern”To simplify setup and ensure all strategies are properly registered, we use the DatasourceModule pattern:
abstract class DatasourceModule<T> { T create(GqlClient client);
void registerStrategies(RequestContext context); void registerCacheHandlers(GqlCacheHandler cacheHandler);}
class ChatMessageDatasourceModule extends DatasourceModule<ChatMessageDatasource> { @override ChatMessageDatasource create(GqlClient client) { final requestContext = RequestContext(); final cacheHandler = GqlCacheHandler(requestContext: requestContext);
registerStrategies(requestContext); registerCacheHandlers(cacheHandler);
return ChatMessageDatasource( client: client, requestContext: requestContext, ); }
@override void registerStrategies(RequestContext context) { context.registerStrategy(ChatMessageListRequestStrategy()); context.registerStrategy(ChatMessageSingleRequestStrategy()); context.registerStrategy(ChatMessageUpsertRequestStrategy()); context.registerStrategy(ChatMessageSubscriptionRequestStrategy()); }
@override void registerCacheHandlers(GqlCacheHandler cacheHandler) { cacheHandler.registerStrategy(ChatMessageUpsertCacheHandlerStrategy()); cacheHandler.registerStrategy(ChatMessageSubscriptionCacheHandlerStrategy()); }}Step 6: Simplified Usage
Section titled “Step 6: Simplified Usage”With the module pattern, creating datasources becomes much cleaner:
// Simple one-liner setupfinal chatMessageDatasource = ChatMessageDatasourceModule().create(gqlClient);
// With dependency injection (Riverpod)@riverpodChatMessageDatasource chatMessageDatasource(ChatMessageDatasourceRef ref) { return ChatMessageDatasourceModule().create(ref.watch(gqlClientProvider));}Key Patterns to Follow
Section titled “Key Patterns to Follow”- Use DatasourceModule: Always create datasources through their module for proper setup
- Context Passing: Use context parameters for subscription filtering and API disambiguation
- Type Safety: Maintain strong typing throughout the entire chain
- Request ID Uniqueness: Ensure request IDs are unique and descriptive
Common Pitfalls
Section titled “Common Pitfalls”- Manual Strategy Registration: Use DatasourceModule instead of manual registration
- Context Serialization: Subscription contexts must be JSON-serializable
- Cache Handler Keys: Must match between request strategies and cache handlers
- Circular Dependencies: Don’t inject datasources into strategies
4. Impact & Consequences
Section titled “4. Impact & Consequences”Positive Consequences
Section titled “Positive Consequences”- Eliminated circular dependencies in dependency injection
- Highly reusable request and cache handling logic
- Context-aware subscriptions solve real-world API limitations
- Type-safe throughout the entire GraphQL operation chain
- Supports Ferry’s advanced features like optimistic updates
- Simplified setup with DatasourceModule pattern reduces boilerplate
Negative Consequences
Section titled “Negative Consequences”- Higher complexity for simple CRUD operations
- More files to maintain (strategies, handlers, contexts)
- Learning curve for team members unfamiliar with Strategy pattern
Monitoring & Success Metrics
Section titled “Monitoring & Success Metrics”- Reduced boilerplate in new GraphQL operations
- Fewer DI-related bugs in datasource layer
- Successful context filtering in chat and real-time features
- Team adoption rate of the pattern