Skip to content

ADR-003: GraphQL Datasource Pattern (Convertor)

GraphQL Datasource Pattern with Convertor

The Convertor pattern provides chainable, composable data pipelines for handling GraphQL operations, cache management, and subscriptions using Ferry.

Amended

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.

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

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.

Implement a Convertor-based GraphQL Datasource with:

  1. Convertors for building request parameters into Ferry operation requests
  2. GraphQLRequestExecutor for executing operations via the Ferry client
  3. GraphQLStreamConvertor for unwrapping Ferry responses into clean data streams
  4. CacheHandlerSpecs + UpdateCacheTypedLink for declarative cache management
  5. Chain, Interceptor, Decorator for composing pipelines
ConcernStrategy PatternConvertor Pattern
BoilerplateHigh (strategy classes, keys, module registration)Low (functional composition)
ComposabilityLimited (fixed strategy slots)High (chainable pipelines)
Type safetyGoodExcellent (covariant input types)
Cache handlingManual handler registrationDeclarative specs with UpdateCacheTypedLink
Learning curveStrategy pattern + custom abstractionsSimple input/output transformation
TestingMock strategies and contextsTest individual convertors in isolation

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 CacheHandlerSpecs instead of imperative handler registration
  • 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)”

The Convertor-based datasource consists of:

  1. Request Convertors - Transform typed parameters into Ferry operation requests
  2. GraphQLRequestExecutor - Execute operations via Ferry client
  3. GraphQLStreamConvertor - Unwrap response streams into data streams
  4. Model Convertors - Transform GQL raw types into domain models
  5. CacheHandlerSpecs - Declarative cache update configuration

A Convertor that builds a Ferry request from typed parameters:

@riverpod
Convertor<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;
});
});
}

Chain the executor with stream conversion and data extraction:

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

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 request
CacheHandlerSpecs.clear(
mapToCachedRequest: Convertor((request) {
return GQueryNotificationReq((b) => b
..requestId = 'notification_list'
..vars = request.vars.toBuilder());
}),
);
// Merge mutation response into cached query
CacheHandlerSpecs.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:

@riverpod
Convertor<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;
}
});
});
}
  1. Use @riverpod for all providers - Codegen is the canonical Riverpod pattern
  2. Chain with .then(), .map(), .thenMap(), .thenEach() for composable pipelines
  3. Use CacheHandlerSpecs for declarative cache management (clear, clearAll, merge)
  4. Repository cleans data - Transform GQL raw models to domain models in the repository
  5. Interceptors for side effects - Logging, analytics, error tracking without modifying data flow
  • 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
  • Functional composition learning curve for developers unfamiliar with the paradigm
  • Complex cache specs still require careful configuration for advanced scenarios
  • 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