Skip to content

ADR-003: GraphQL Datasource Pattern

GraphQL Datasource Pattern with Strategy & Context

A sophisticated pattern for handling GraphQL operations, cache management, and context-aware subscriptions using Ferry while avoiding circular dependencies.

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.

  • 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 → client dependency 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/user2 instead of clear current/other user)
    • Subscriptions need context filtering (only show messages for current chat and user)

Following Ferry’s documentation directly led to circular dependencies with Riverpod:

// Ferry's example approach - creates circular dependency
@riverpod
Client 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'),
]),
);
}
@riverpod
Map<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

Implement a Strategy Pattern-based GraphQL Datasource with:

  1. Request Strategies for building different types of operations
  2. Cache Handler Strategies for managing optimistic updates
  3. Request Context for dependency injection and state management
  4. Context-aware subscriptions for filtering real-time data
  1. Direct Ferry Usage

    • Pros: Simple, follows official docs
    • Cons: Circular dependencies, repetitive code, no context handling
  2. Repository Pattern Only

    • Pros: Clean abstraction
    • Cons: Still has DI issues, doesn’t solve Ferry-specific problems
  3. Custom GraphQL Client

    • Pros: Full control
    • Cons: Reinventing the wheel, losing Ferry’s benefits
  4. Chosen: Strategy Pattern with Context

    • Pros: Solves DI issues, reusable, context-aware, type-safe
    • Cons: More complex initially, requires understanding of pattern

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
  • 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)”

The pattern consists of four main components:

  1. Base Datasource Interfaces - Define contracts for different operation types
  2. Request Strategies - Handle building specific GraphQL requests
  3. Cache Handler Strategies - Manage optimistic updates and cache operations
  4. Request Context - Coordinate strategies and manage dependencies
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});
}

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 example
class 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;
}

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());
}
}

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
};
}
}

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());
}
}

With the module pattern, creating datasources becomes much cleaner:

// Simple one-liner setup
final chatMessageDatasource = ChatMessageDatasourceModule().create(gqlClient);
// With dependency injection (Riverpod)
@riverpod
ChatMessageDatasource chatMessageDatasource(ChatMessageDatasourceRef ref) {
return ChatMessageDatasourceModule().create(ref.watch(gqlClientProvider));
}
  1. Use DatasourceModule: Always create datasources through their module for proper setup
  2. Context Passing: Use context parameters for subscription filtering and API disambiguation
  3. Type Safety: Maintain strong typing throughout the entire chain
  4. Request ID Uniqueness: Ensure request IDs are unique and descriptive
  • 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
  • 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
  • Higher complexity for simple CRUD operations
  • More files to maintain (strategies, handlers, contexts)
  • Learning curve for team members unfamiliar with Strategy pattern
  • 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