Skip to content

ADR-005: Convertor Replaces Strategy Pattern

Convertor Replaces Strategy Pattern

The Convertor pattern is now the sole datasource pattern in MOFA architecture. The Strategy/DatasourceModule pattern is retired.

The Strategy/DatasourceModule pattern, while solving Ferry’s circular dependency problem (see ADR-003), introduced significant boilerplate and complexity:

  • Excessive class proliferation: Each feature required RequestStrategy, CacheHandlerStrategy, strategy key enums, RequestContext, and DatasourceModule classes
  • Manual registration: Strategies had to be manually registered in the module, creating opportunities for runtime errors when registrations were missed
  • Rigid composition: The pattern didn’t compose well - combining strategies or reusing parts across features required additional abstraction
  • Testing overhead: Mocking the strategy registration chain was complex and brittle
  • Conversion extension problem: Manual mapping between GQL types and domain models via extensions led to forgotten fields discovered only at runtime
  • When: After implementing the Strategy pattern across multiple production features
  • Where: All Flutter projects using MOFA architecture with GraphQL
  • Who: Flutter architecture team
  • Constraints:
    • Must still solve Ferry’s circular dependency problem
    • Must integrate with Riverpod dependency injection
    • Must support complex cache operations (optimistic updates, cache merging)
    • Should reduce boilerplate while maintaining type safety

Replace the Strategy/DatasourceModule pattern with the Convertor pattern as the sole datasource abstraction in MOFA architecture. The Convertor pattern is implemented as a shared Dart package reusable across all projects.

The following concepts from the Strategy pattern are retired:

Retired ConceptConvertor Replacement
RequestStrategyConvertor<OperationRequest, Params>
CacheHandlerStrategyCacheHandlerSpecs + UpdateCacheTypedLink
RequestContextDirect Convertor composition
DatasourceModule@riverpod provider functions
GqlDatasource abstract classGraphQLRequestExecutor Convertor
Strategy key enumsNot needed (no registration)
Manual strategy registrationNot needed (direct composition)
New ConceptPurpose
Convertor<To, From>Core unit: accepts input, produces output
StreamConvertor<To, From>Convertor producing Stream<To>
AsyncConvertor<To, From>Convertor producing Future<To>
ChainSequence of Convertors composed together
InterceptorSide effects without modifying data flow
DecoratorModify input/output or bypass Convertor
GraphQLRequestExecutorExecutes Ferry operations
GraphQLStreamConvertorUnwraps Ferry response streams
CacheHandlerSpecsDeclarative cache operation configuration
UpdateCacheTypedLinkFerry TypedLink that applies CacheHandlerSpecs
  1. Improve Strategy Pattern

    • Add code generation for strategy registration
    • Pros: Minimal migration effort
    • Cons: Doesn’t address fundamental complexity; still too many classes
  2. Repository-Only Pattern

    • Move all logic into repositories, remove datasource abstraction
    • Pros: Simple
    • Cons: Loses composability; repositories become bloated; no reuse across features
  3. Convertor Pattern (Chosen)

    • Functional, composable pipelines
    • Pros: Minimal boilerplate, highly composable, testable, type-safe
    • Cons: Requires learning functional composition

The Convertor pattern was chosen because:

  1. Solves the same problems as Strategy (circular dependencies, type safety, cache management) with far less code
  2. Composability - Convertors chain naturally: executor.then(streamConvertor).map(extract).thenEach(modelConvertor)
  3. No registration - No module pattern needed; Convertors are composed directly in @riverpod providers
  4. Declarative cache - CacheHandlerSpecs replaces imperative cache handler registration with clear intent
  5. Conversion extension problem solved - Ferry build.yaml custom type mapping generates Dart classes directly for GQL types, eliminating manual conversion extensions that led to forgotten fields
  6. Shared package - Convertor is a standalone Dart package, reusable across all MOFA projects
// 1. Strategy key enum
enum ChatRequestStrategyKeys { chatList, chatUpsert }
// 2. Request strategy class
class ChatListRequestStrategy
implements ListItemsRequestStrategy<GQueryChatReq, GchatFilters, GchatOrder> {
@override
final String baseRequestId = 'chat';
@override
GQueryChatReq createRequest(ListRequestParams params) { /* ... */ }
// ... more boilerplate
}
// 3. Cache handler strategy class
class ChatUpsertCacheHandlerStrategy
implements CacheHandlerStrategy<GMutateChatSaveData, GMutateChatSaveVars> {
@override
UpdateCacheHandler build(RequestContext requestContext) { /* ... */ }
}
// 4. Datasource module
class ChatDatasourceModule extends DatasourceModule<ChatDatasource> {
@override
ChatDatasource create(GqlClient client) {
final requestContext = RequestContext();
registerStrategies(requestContext);
registerCacheHandlers(/* ... */);
return ChatDatasource(client: client, requestContext: requestContext);
}
@override
void registerStrategies(RequestContext context) {
context.registerStrategy(ChatListRequestStrategy());
context.registerStrategy(ChatUpsertRequestStrategy());
}
// ...
}
// 5. Riverpod provider
@riverpod
ChatDatasource chatDatasource(ChatDatasourceRef ref) {
return ChatDatasourceModule().create(ref.watch(gqlClientProvider));
}
// 1. Request convertor (replaces strategy class + key enum)
@riverpod
Convertor<GQueryChatReq, ListRequestParams<GchatFilters, GchatOrder>>
chatListQueryConvertor(Ref ref) {
return Convertor((params) {
return GQueryChatReq((b) => b
..requestId = 'chat_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);
});
}
// 2. Datasource pipeline (replaces datasource module + abstract datasource)
@riverpod
StreamConvertor<List<GQueryChatData_chat_chatItems>, ListRequestParams<GchatFilters, GchatOrder>>
chatListDatasource(Ref ref) {
return GraphQLRequestExecutor<GQueryChatData,
ListRequestParams<GchatFilters, GchatOrder>, GQueryChatVars>(
gqlClient: ref.watch(gqlClientProvider),
convertor: ref.watch(chatListQueryConvertorProvider),
)
.then(GraphQLStreamConvertor())
.map((data) => data.chat!.chatItems!.nonNulls.toList());
}
// 3. Cache specs (replaces cache handler strategy class)
// Defined declaratively in the UpdateCacheTypedLink configuration
  • No more strategy key enums - Convertors are composed directly, no registration
  • No more DatasourceModule - @riverpod providers replace module factories
  • No more abstract GqlDatasource - GraphQLRequestExecutor is the standard executor
  • Cache handling is declarative - Use CacheHandlerSpecs with UpdateCacheTypedLink
  • Model conversion stays in repository - Use .thenMap() / .thenEach() with model convertors
  • ~60% reduction in datasource code per feature
  • Eliminated forgotten strategy registration bugs (no registration needed)
  • Composable building blocks reusable across features
  • Simpler testing - test Convertors as pure functions
  • Solved conversion extension problem via Ferry custom type mapping
  • Migration effort for existing features using Strategy pattern
  • Learning curve for functional composition paradigm
  • Two patterns in codebase during migration period (mitigated by completing migration before shipping)
  • All new features use Convertor pattern exclusively
  • No references to DatasourceModule, RequestStrategy, or CacheHandlerStrategy in active code
  • Measurable reduction in lines of code per feature datasource