Skip to content

Three-Model Layer Pipeline

Three-Model Layer Pipeline

Data flows through three distinct model types, each owned by a different layer. Understanding this pipeline is essential for correct data handling.

MOFA architecture uses three distinct model types to ensure clean separation between external API shapes and internal domain representations:

GQL Raw Models -> Domain Models -> Presentation State
(Datasource) (Domain) (UI)

Each transformation is explicit and handled by a specific component.

  • Dart classes generated by Ferry from your GraphQL schema
  • Configured via build.yaml custom type mapping to generate plain Dart classes (not built_value)
  • Represent the exact shape of GraphQL responses
packages/datasource/lib/src/[feature]/remote/gql/
├── [feature]_operations.graphql # GraphQL queries, mutations, subscriptions
├── __generated__/ # Ferry-generated Dart classes
│ ├── [feature]_operations.req.gql.dart
│ ├── [feature]_operations.data.gql.dart
│ └── [feature]_operations.var.gql.dart
  • Generated code - never manually edited
  • May contain nullable fields that don’t match domain expectations
  • May contain GQL-specific types (e.g., GDateTime, custom scalars)
  • May contain nested structures that differ from the flat domain model
  • Field names may not match domain naming conventions

In build.yaml, custom scalar types are mapped to Dart types:

targets:
$default:
builders:
ferry_generator:
options:
schema: schema.graphql
type_overrides:
DateTime:
name: DateTime
import: 'dart:core'
JSON:
name: Map<String, dynamic>
import: 'dart:core'
Upload:
name: MultipartFile
import: 'package:http/http.dart'

This mapping ensures GQL types resolve to standard Dart classes, eliminating the need for built_value serialization for custom scalars.

  • Clean, business-meaningful models defined with @freezed
  • All nullable fields resolved with defaults or explicit handling
  • Repository-processed: data cleaning, null handling, type conversions applied
  • The “source of truth” for business entities
packages/domain/lib/src/[feature]/
├── models/
│ ├── [feature]_model.dart # Main domain model
│ └── [feature]_related_model.dart # Related models
├── inputs/
│ ├── create_[feature]_input.dart # Create input type
│ └── update_[feature]_input.dart # Update input type
├── filters/
│ └── [feature]_filter_criteria.dart
├── sorts/
│ └── [feature]_sort_criteria.dart
├── exceptions/
│ └── [feature]_exceptions.dart
└── repositories/
└── i_[feature]_repository.dart # Repository interface
  • Immutable (@freezed)
  • Non-nullable fields where business rules require values
  • Clean naming conventions (no GQL prefixes like G)
  • No dependency on datasource layer types
  • Ready for UI consumption without further transformation
@freezed
class NotificationModel with _$NotificationModel {
const factory NotificationModel({
required String id,
required String title,
required DateTime date,
required List<AttachmentModel> attachments,
@Default(false) bool isRead,
}) = _NotificationModel;
}
  • Wrappers around domain models for UI-specific concerns
  • AsyncValue<T> for loading/error/data states
  • PaginationState<T> for paginated lists
  • Form state models for input forms
lib/features/[feature]/ui/notifiers/
├── [feature]_notifier.dart # Manages presentation state
└── [feature]_form_notifier.dart # Form-specific state
  • Managed by @riverpod notifiers
  • Adds loading, error, and empty states around domain models
  • May compose multiple domain models for a single view
  • Contains no business logic

GQL Raw -> Domain (Repository + Model Convertor)

Section titled “GQL Raw -> Domain (Repository + Model Convertor)”

The repository is responsible for this transformation using Convertor-based model convertors:

// Model convertor: GQL raw type -> Domain model
@riverpod
Convertor<NotificationModel,
GQueryNotificationData_notification_notificationItems>
notificationModelConvertor(Ref ref) {
return Convertor((raw) {
return NotificationModel(
id: raw.id ?? '',
title: raw.title ?? 'Untitled',
date: raw.date ?? DateTime.now(),
attachments: raw.images
?.map((i) => AttachmentModel(id: i?.id ?? '', mediaUrl: i?.mediaUrl ?? ''))
.toList() ?? [],
isRead: raw.isRead ?? false,
);
});
}

Applied in the repository via .thenMap() or .thenEach():

// Single item
return itemDatasource.thenMap(modelConvertor).execute(params);
// List of items
return listDatasource.thenEach(modelConvertor).execute(params);

Domain -> GQL Mutation Vars (Filter/Sort/Input Convertors)

Section titled “Domain -> GQL Mutation Vars (Filter/Sort/Input Convertors)”

Convertors also handle the reverse direction for mutations, filters, and sorts:

// Filter convertor: Domain filter -> GQL filter
@riverpod
Convertor<GnotificationFilters, NotificationFilterCriteria>
notificationFilterConvertor(Ref ref) {
return Convertor((filter) {
return GnotificationFilters((b) => b
..id = filter.id
..systemParentId = filter.systemParentId
..title = GStringFiltersBuilder().construct(contains: filter.title));
});
}
// Sort convertor: Domain sort -> GQL sort
@riverpod
Convertor<GnotificationOrder, NotificationSortCriteria>
notificationSortConvertor(Ref ref) {
return Convertor((sort) {
return GnotificationOrder((b) => b
..id = sort.id?.value
..date = sort.date?.value);
});
}

Notifiers wrap domain data in AsyncValue or pagination state:

@riverpod
class NotificationListNotifier extends _$NotificationListNotifier {
@override
Stream<List<NotificationModel>> build() {
final repository = ref.watch(notificationRepositoryProvider);
return repository.queryList(
filter: NotificationFilterCriteria(eventId: eventId),
sort: NotificationSortCriteria(date: SortOptions.descending),
);
}
}
// Consumer receives AsyncValue<List<NotificationModel>>
ConcernWithout Three LayersWith Three Layers
GQL schema changeRipples through entire appOnly repository convertor needs updating
Null handlingScattered ?? in UICentralized in model convertor
Field renamingFind-and-replace everywhereUpdate convertor mapping only
TestingMust mock GQL types in UI testsTest with clean domain models
API migrationRewrite entire featureSwap datasource, keep domain + UI
  1. Never expose GQL raw types above the repository - Notifiers and UI only see domain models
  2. Repositories own the transformation - Model convertors are applied in repository methods
  3. Domain models are always valid - No nullable fields unless the business domain allows null
  4. Presentation state wraps, doesn’t transform - Notifiers add loading/error states, not business logic
  5. Ferry custom type mapping for scalars - Configure in build.yaml, not manual conversion extensions