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.
Three-Model Layer Pipeline
Section titled “Three-Model Layer Pipeline”Overview
Section titled “Overview”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.
Layer 1: GQL Raw Models
Section titled “Layer 1: GQL Raw Models”What They Are
Section titled “What They Are”- Dart classes generated by Ferry from your GraphQL schema
- Configured via
build.yamlcustom type mapping to generate plain Dart classes (not built_value) - Represent the exact shape of GraphQL responses
Where They Live
Section titled “Where They Live”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.dartCharacteristics
Section titled “Characteristics”- 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
Ferry Custom Type Mapping
Section titled “Ferry Custom Type Mapping”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.
Layer 2: Domain Models
Section titled “Layer 2: Domain Models”What They Are
Section titled “What They Are”- 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
Where They Live
Section titled “Where They Live”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 interfaceCharacteristics
Section titled “Characteristics”- 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
Example
Section titled “Example”@freezedclass NotificationModel with _$NotificationModel { const factory NotificationModel({ required String id, required String title, required DateTime date, required List<AttachmentModel> attachments, @Default(false) bool isRead, }) = _NotificationModel;}Layer 3: Presentation State
Section titled “Layer 3: Presentation State”What They Are
Section titled “What They Are”- Wrappers around domain models for UI-specific concerns
AsyncValue<T>for loading/error/data statesPaginationState<T>for paginated lists- Form state models for input forms
Where They Live
Section titled “Where They Live”lib/features/[feature]/ui/notifiers/├── [feature]_notifier.dart # Manages presentation state└── [feature]_form_notifier.dart # Form-specific stateCharacteristics
Section titled “Characteristics”- Managed by
@riverpodnotifiers - Adds loading, error, and empty states around domain models
- May compose multiple domain models for a single view
- Contains no business logic
Transformation Responsibilities
Section titled “Transformation Responsibilities”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@riverpodConvertor<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 itemreturn itemDatasource.thenMap(modelConvertor).execute(params);
// List of itemsreturn 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@riverpodConvertor<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@riverpodConvertor<GnotificationOrder, NotificationSortCriteria>notificationSortConvertor(Ref ref) { return Convertor((sort) { return GnotificationOrder((b) => b ..id = sort.id?.value ..date = sort.date?.value); });}Domain -> Presentation State (Notifier)
Section titled “Domain -> Presentation State (Notifier)”Notifiers wrap domain data in AsyncValue or pagination state:
@riverpodclass 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>>Why Three Layers?
Section titled “Why Three Layers?”| Concern | Without Three Layers | With Three Layers |
|---|---|---|
| GQL schema change | Ripples through entire app | Only repository convertor needs updating |
| Null handling | Scattered ?? in UI | Centralized in model convertor |
| Field renaming | Find-and-replace everywhere | Update convertor mapping only |
| Testing | Must mock GQL types in UI tests | Test with clean domain models |
| API migration | Rewrite entire feature | Swap datasource, keep domain + UI |
Key Rules
Section titled “Key Rules”- Never expose GQL raw types above the repository - Notifiers and UI only see domain models
- Repositories own the transformation - Model convertors are applied in repository methods
- Domain models are always valid - No nullable fields unless the business domain allows null
- Presentation state wraps, doesn’t transform - Notifiers add loading/error states, not business logic
- Ferry custom type mapping for scalars - Configure in
build.yaml, not manual conversion extensions