Unyo Domain & Data Layer
This skill covers the domain/data architecture in Unyo: entities, models, and repositories. Everything starts here — before you add a cubit, screen, or notifier, you need the domain and data layer in place.
Architecture Overview
lib/
├── domain/
│ ├── entities/ # Abstract business objects
│ │ ├── anime.dart # Abstract class + Freezed implementation
│ │ └── entities.dart # Barrel export
│ └── repositories/ # Abstract repository contracts
│ ├── anime_repository.dart
│ └── repositories.dart # Barrel export
├── data/
│ ├── models/ # Concrete Freezed models with JSON mapping
│ │ ├── anilist_anime_model.dart
│ │ └── models.dart # Barrel export
│ ├── repositories/ # Repository implementations
│ │ ├── anime_repository_anilist.dart
│ │ ├── repository_mixin.dart
│ │ └── repositories.dart # Barrel export
│ └── adapters/ # Hive type adapters
├── generated/json/ # Generated JSON serialization code
└── core/services/api/ # API services and DTOs
├── graphql/ # GraphQL service, queries, responses
├── http/ # HTTP service, responses, exceptions
└── dto/ # Data Transfer Objects (API response models)
Pattern: Entity Definition
Domain entities live in lib/domain/entities/. They use a dual-class pattern: an abstract base class defining the contract, and a Freezed-generated implementation class.
Why two classes? The abstract class lets repositories return a stable domain type while data models can vary by provider (AniList, MAL, etc.). The Freezed class gets copyWith, equality, and JSON serialization for free.
Creating a new entity
- Create the abstract class and Freezed model in one file:
// lib/domain/entities/anime.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'airing_episode.dart';
import 'title.dart';
part 'anime.freezed.dart';
part 'anime.g.dart';
// Abstract base — the domain contract
abstract class Anime {
final int id;
final String coverImage;
// ... all fields as final
const Anime({
required this.id,
required this.coverImage,
// ... all required parameters
});
}
// Freezed implementation — adds copyWith, equality, JSON
@freezed
abstract class AnimeModel with _$AnimeModel implements Anime {
const factory AnimeModel({
required int id,
@TitleConverter() required Title title,
required String coverImage,
// ... all fields matching the abstract class
}) = _AnimeModel;
// Factory for empty/default instances (used in initial state)
factory AnimeModel.empty() => AnimeModel(
id: -1,
title: TitleModel.empty(),
coverImage: '',
);
// JSON serialization
factory AnimeModel.fromJson(Map<String, dynamic> json) =>
_$AnimeModelFromJson(json);
// Required for toJson on the abstract type
@override
Map<String, dynamic> toJson() =>
_$AnimeModelToJson(this as _AnimeModel);
}
// Converter for use in other Freezed classes that reference this type
class AnimeConverter
implements JsonConverter<Anime, Map<String, dynamic>> {
const AnimeConverter();
@override
Anime fromJson(Map<String, dynamic> json) =>
AnimeModel.fromJson(json);
@override
Map<String, dynamic> toJson(Anime object) =>
(object as AnimeModel).toJson();
}
Always pair abstract + Freezed — never use Freezed alone for a domain entity that other code references. Other code should depend on the abstract class, not the model.
Custom JSON converters are needed when a Freezed class references another abstract entity type. The converter delegates to the concrete model's
fromJson/toJson. Name them<EntityName>Converter.Empty factories are essential — cubit initial states need default values. Every entity should have an
.empty()factory.Add the entity to the barrel export in
lib/domain/entities/entities.dart.Run code generation after creating or modifying any entity:
flutter pub run build_runner build --delete-conflicting-outputs
Pattern: Data Model with API Mapping
Data models in lib/data/models/ extend domain entities by adding factory constructors that map from API response DTOs.
Key conventions
- Models use Freezed
implements Anime(the abstract class), NOTextends Anime - Named factories like
fromScheduleEntry(),fromPopularOrTrendingMediaEntry()map specific API responses - The model file has
part 'model_name.freezed.dart'andpart 'model_name.g.dart' - Models must match all fields from the abstract class exactly (same names, same types)
Creating a model for a new data source
// lib/data/models/anilist_anime_model.dart
@freezed
abstract class AnilistAnimeModel with _$AnilistAnimeModel implements Anime {
const AnilistAnimeModel._(); // Private constructor for custom methods
const factory AnilistAnimeModel({
required int id,
required String coverImage,
// ... all fields from Anime abstract class
}) = _AnilistAnimeModel;
// Map from AniList API DTO
factory AnilistAnimeModel.fromScheduleEntry(
MediaCollectionRecentlyReleasedDto schedule) {
return AnilistAnimeModel(
id: schedule.media.id,
coverImage: schedule.media.coverImage.large,
// ... map all fields
);
}
factory AnilistAnimeModel.fromJson(Map<String, dynamic> json) =>
_$AnilistAnimeModelFromJson(json);
}
Important: When there are multiple API sources (AniList, MAL, etc.), each source gets its own model file (e.g., mal_anime_model.dart) but they all implement the same Anime abstract class.
Pattern: Repository Interface & Implementation
Repositories follow a strict interface-implementation separation:
1. Define the repository interface
// lib/domain/repositories/anime_repository.dart
abstract class AnimeRepository {
Future<(bool, List<Anime>)> getTrendingAnimes(int page, User loggedUser);
Future<(bool, AnimeDetails)> getAnimeDetails(Anime selectedAnime, User loggedUser);
// ... all methods as abstract
}
Convention: Methods return Dart records (bool, List<Anime>) where the bool indicates if there's a next page. Methods take User typed parameters (the abstract User, not AnilistUserModel).
2. Implement with RepositoryMixin
// lib/data/repositories/anime_repository_anilist.dart
class AnimeRepositoryAnilist with RepositoryMixin implements AnimeRepository {
final GraphQLService _anilistGraphQLService = sl<GraphQLService>(
instanceName: config.anilistGraphQlService,
);
final Logger _logger = sl<Logger>();
@override
Future<(bool, List<Anime>)> getTrendingAnimes(int page, User loggedUser) async {
// 1. Call the API service
final response = await _anilistGraphQLService.query(
query: anilist_queries.mediaTrendingOrPopularQuery,
fromJson: MediaCollectionTrendingOrPopularGraphqlEntity.fromJson,
variables: {"sort": "TRENDING_DESC", "page": page, "perPage": 30, "type": "ANIME"},
);
// 2. Use RepositoryMixin to check for errors
throwIfGraphQlError(response);
// 3. Map DTOs to domain models
final animes = response.data.page.media
.map((entry) => AnilistAnimeModel.fromPopularOrTrendingMediaEntry(entry))
.toList();
return (true, animes);
}
}
Key patterns:
- Use
RepositoryMixin.throwIfGraphQlError()to check for API errors — this throws aGraphQLExceptionthat cubits catch - Name implementations after their data source:
AnimeRepositoryAnilist,AnimeRepositoryMal, etc. - Inject services via
sl<T>()(service locator), not constructor params (repositories are singletons)
3. Add to barrel export
// lib/domain/repositories/repositories.dart
export 'anime_repository.dart';
// lib/data/repositories/repositories.dart
export 'anime_repository_anilist.dart';
Pattern: API DTOs (Data Transfer Objects)
API response DTOs live in lib/core/services/api/dto/. They are generated from JSON using json_serializable:
// lib/core/services/api/dto/anilist/media_collection_trendingOrPopular_graphql_entity.dart
// These are typically auto-generated or manually created from the API schema
// They use json_annotation and have corresponding .g.dart files
Convention: DTO class names describe the API response structure (e.g., MediaCollectionTrendingOrPopularGraphqlEntity). They are internal to the data layer and never leak into domain or application code.
Pattern: Hive Type Adapters
For entities that need local persistence via Hive:
- The entity file must use
@HiveType(typeId: N)annotations - An adapter file in
lib/data/adapters/handles serialization - Register adapters in
main.dartviaHive.registerAdapter()orHive.registerAdapters()(auto-generated)
Workflow: Adding a New Feature's Data Layer
When adding a completely new feature (e.g., "Studio browsing"):
- Entity: Create
lib/domain/entities/studio.dartwith abstract class + Freezed model + converter - Barrel: Add export to
lib/domain/entities/entities.dart - Model (if mapping from API): Create
lib/data/models/anilist_studio_model.dartwith mapping factories - Repository interface: Create
lib/domain/repositories/studio_repository.dart - Repository impl: Create
lib/data/repositories/studio_repository_anilist.dartusingRepositoryMixin - API DTOs (if needed): Create in
lib/core/services/api/dto/anilist/ - GraphQL queries (if using GraphQL): Add to
lib/core/services/api/graphql/queries/anilist_queries.dart - Register in DI: Add to
lib/core/di/locator.dart(seeunyo-dependency-injectionskill) - Run code gen:
flutter pub run build_runner build --delete-conflicting-outputs - Run analysis:
flutter analyze
Common Pitfalls
- Missing
partdirectives: Every entity/model file needspart 'filename.freezed.dart'andpart 'filename.g.dart'(if using JSON). - Field mismatches: The Freezed factory must match the abstract class fields exactly. Missing or extra fields cause compile errors.
- Converter forgetfulness: If entity A references entity B via the abstract type in a Freezed class, you need a
BConverter. Without it, Freezed generates code that tries to serialize the abstract type directly. - Forget to run build_runner: After any change to
@freezed,@JsonSerializable, or@HiveTypeannotated files, you must regenerate. - Leaking DTOs: Never import DTO types in domain or presentation layers. The repository methods should return domain entities, and internal mapping from DTO to entity happens inside the repository implementation.
Cross-references
- Registering repositories in DI: See
unyo-dependency-injectionskill - Using repositories in cubits: See
unyo-bloc-state-managementskill - Sharing entity state across cubits: See
unyo-reactive-notifiersskill