Data Layer Guide
The data layer handles data persistence, serialization, and implements the repository interfaces defined in the domain layer. It's responsible for converting between domain entities and data models, and managing external data sources.
Structure
data/
├── models/
│ └── feature_model.dart
└── repositories/
└── local_feature_repository.dart1. Creating Data Models
Data models handle JSON serialization and conversion to/from domain entities. They should mirror domain entities but include serialization logic.
Model Template
dart
import 'package:flutter_boilerplate/features/[feature]/domain/entities/feature_entity.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'feature_model.freezed.dart';
part 'feature_model.g.dart';
/// Data model for [FeatureName] persistence and serialization.
///
/// Handles JSON serialization and conversion to/from the [FeatureName] domain entity.
/// Used in the data layer for API calls and local storage.
@freezed
abstract class FeatureModel with _$FeatureModel {
/// Creates a [FeatureModel] with the specified properties.
///
/// [id] - Unique identifier for the [FeatureName].
/// [property1] - Description of property1.
/// [property2] - Description of property2.
/// [createdAt] - Timestamp when created.
/// [updatedAt] - Timestamp when last modified.
const factory FeatureModel({
required String id,
required String property1,
String? property2,
required DateTime createdAt,
required DateTime updatedAt,
}) = _FeatureModel;
/// Private constructor for extending functionality.
const FeatureModel._();
/// Creates a [FeatureModel] from a [FeatureName] domain entity.
///
/// [entity] - The domain entity to convert.
factory FeatureModel.fromDomain(FeatureName entity) => FeatureModel(
id: entity.id,
property1: entity.property1,
property2: entity.property2,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
);
/// Creates a [FeatureModel] from a JSON map.
///
/// [json] - The JSON map to deserialize.
factory FeatureModel.fromJson(Map<String, dynamic> json) =>
_$FeatureModelFromJson(json);
/// Converts this model to a [FeatureName] domain entity.
FeatureName toDomain() => FeatureName(
id: id,
property1: property1,
property2: property2,
createdAt: createdAt,
updatedAt: updatedAt,
);
}Key Points for Data Models
- Freezed + JSON: Use both
@freezedand JSON serialization annotations - Domain Conversion: Provide
fromDomain()andtoDomain()methods - JSON Serialization: Include
fromJson()factory andtoJson()method (auto-generated) - Mirror Domain: Properties should match the domain entity exactly
- Documentation: Document the model's purpose and conversion methods
Real Example (Note Model)
dart
/// Data model for note persistence and serialization.
///
/// Handles JSON serialization and conversion to/from the [Note] domain entity.
/// Used in the data layer for SharedPreferences storage.
@freezed
abstract class NoteModel with _$NoteModel {
const factory NoteModel({
required String id,
required String userId,
required String title,
required String content,
required DateTime createdAt,
required DateTime updatedAt,
}) = _NoteModel;
const NoteModel._();
factory NoteModel.fromDomain(Note note) => NoteModel(
id: note.id,
userId: note.userId,
title: note.title,
content: note.content,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
);
factory NoteModel.fromJson(Map<String, dynamic> json) =>
_$NoteModelFromJson(json);
Note toDomain() => Note(
id: id,
userId: userId,
title: title,
content: content,
createdAt: createdAt,
updatedAt: updatedAt,
);
}2. Creating Repository Implementations
Repository implementations provide concrete data access logic while implementing the domain repository interface.
Local Repository Template (SharedPreferences)
dart
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter_boilerplate/core/error/failure.dart';
import 'package:flutter_boilerplate/core/services/logger_service.dart';
import 'package:flutter_boilerplate/features/[feature]/data/models/feature_model.dart';
import 'package:flutter_boilerplate/features/[feature]/domain/entities/feature_entity.dart';
import 'package:flutter_boilerplate/features/[feature]/domain/repositories/feature_repository.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Local implementation of [FeatureRepository] using SharedPreferences.
///
/// Provides CRUD operations for [FeatureName] with local persistence.
/// Emits updates through a stream when items are modified.
class LocalFeatureRepository implements FeatureRepository {
/// Creates a [LocalFeatureRepository] with the given [SharedPreferences].
///
/// [sharedPreferences] - The SharedPreferences instance for persistence.
LocalFeatureRepository({required SharedPreferences sharedPreferences})
: _sharedPreferences = sharedPreferences;
final SharedPreferences _sharedPreferences;
/// Storage key for items in SharedPreferences.
static const _itemsKey = 'feature_items';
/// Stream controller for broadcasting changes.
final _itemsController = StreamController<(List<FeatureName>, Failure?)>.broadcast();
/// Random instance for generating unique IDs.
final _random = Random();
@override
Stream<(List<FeatureName>, Failure?)> listenForChanges() {
// Emit current items after a microtask to ensure subscription is ready
unawaited(Future.microtask(_emitCurrentItems));
return _itemsController.stream;
}
@override
Future<(FeatureName?, Failure?)> getById(String id) async {
try {
final items = _loadItems();
final item = items.where((n) => n.id == id).firstOrNull;
if (item == null) {
return (null, const Failure(message: 'Item not found'));
}
return (item, null);
} on Exception catch (e) {
logger.e('[LocalFeatureRepository] Failed to get item by id', error: e);
return (null, const Failure(message: 'Failed to load item'));
}
}
@override
Future<(FeatureName?, Failure?)> create({
required String property1,
String? property2,
}) async {
try {
// Validate required properties
if (property1.trim().isEmpty) {
return (null, const Failure(message: 'Property1 cannot be empty'));
}
final now = DateTime.now();
final item = FeatureName(
id: _generateUniqueId(),
property1: property1,
property2: property2,
createdAt: now,
updatedAt: now,
);
final items = _loadItems()..add(item);
await _saveItems(items);
_emitCurrentItems();
logger.d('[LocalFeatureRepository] Created item: ${item.id}');
return (item, null);
} on Exception catch (e) {
logger.e('[LocalFeatureRepository] Failed to create item', error: e);
return (null, const Failure(message: 'Failed to save item'));
}
}
@override
Future<(FeatureName?, Failure?)> update({
required String id,
required String property1,
String? property2,
}) async {
try {
// Validate required properties
if (property1.trim().isEmpty) {
return (null, const Failure(message: 'Property1 cannot be empty'));
}
final items = _loadItems();
final index = items.indexWhere((n) => n.id == id);
if (index == -1) {
return (null, const Failure(message: 'Item not found'));
}
final existingItem = items[index];
final updatedItem = FeatureName(
id: existingItem.id,
property1: property1,
property2: property2,
createdAt: existingItem.createdAt,
updatedAt: DateTime.now(),
);
items[index] = updatedItem;
await _saveItems(items);
_emitCurrentItems();
logger.d('[LocalFeatureRepository] Updated item: ${updatedItem.id}');
return (updatedItem, null);
} on Exception catch (e) {
logger.e('[LocalFeatureRepository] Failed to update item', error: e);
return (null, const Failure(message: 'Failed to save item'));
}
}
@override
Future<(void, Failure?)> delete(String id) async {
try {
final items = _loadItems();
final index = items.indexWhere((n) => n.id == id);
if (index == -1) {
return (null, const Failure(message: 'Item not found'));
}
items.removeAt(index);
await _saveItems(items);
_emitCurrentItems();
logger.d('[LocalFeatureRepository] Deleted item: $id');
return (null, null);
} on Exception catch (e) {
logger.e('[LocalFeatureRepository] Failed to delete item', error: e);
return (null, const Failure(message: 'Failed to delete item'));
}
}
/// Disposes the stream controller.
///
/// Should be called when the repository is no longer needed.
Future<void> dispose() async {
await _itemsController.close();
}
/// Loads items from SharedPreferences.
List<FeatureName> _loadItems() {
try {
final itemsJson = _sharedPreferences.getString(_itemsKey);
if (itemsJson == null) {
return [];
}
final itemsList = jsonDecode(itemsJson) as List<dynamic>;
return itemsList
.map((json) => FeatureModel.fromJson(json as Map<String, dynamic>))
.map((model) => model.toDomain())
.toList();
} on Exception catch (e) {
logger.e('[LocalFeatureRepository] Failed to load items', error: e);
return [];
}
}
/// Saves items to SharedPreferences.
Future<void> _saveItems(List<FeatureName> items) async {
final models = items.map(FeatureModel.fromDomain).toList();
final jsonList = models.map((m) => m.toJson()).toList();
final itemsJson = jsonEncode(jsonList);
await _sharedPreferences.setString(_itemsKey, itemsJson);
}
/// Emits the current items list to the stream.
///
/// Items are sorted by updatedAt in descending order (newest first).
void _emitCurrentItems() {
try {
final items = _loadItems()
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
_itemsController.add((items, null));
} on Exception catch (e) {
logger.e('[LocalFeatureRepository] Failed to emit items', error: e);
_itemsController.add((
[],
const Failure(message: 'Failed to load items'),
));
}
}
/// Generates a unique ID for a new item.
///
/// Uses timestamp and random values to ensure uniqueness.
String _generateUniqueId() {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final randomPart = _random.nextInt(999999).toString().padLeft(6, '0');
return '$timestamp-$randomPart';
}
}API Repository Template
dart
import 'package:dio/dio.dart';
import 'package:flutter_boilerplate/core/error/failure.dart';
import 'package:flutter_boilerplate/core/services/logger_service.dart';
import 'package:flutter_boilerplate/features/[feature]/data/models/feature_model.dart';
import 'package:flutter_boilerplate/features/[feature]/domain/entities/feature_entity.dart';
import 'package:flutter_boilerplate/features/[feature]/domain/repositories/feature_repository.dart';
/// API implementation of [FeatureRepository] using HTTP calls.
///
/// Provides CRUD operations for [FeatureName] with remote persistence.
class ApiFeatureRepository implements FeatureRepository {
/// Creates an [ApiFeatureRepository] with the given [Dio] client.
///
/// [dio] - The HTTP client for API calls.
ApiFeatureRepository({required Dio dio}) : _dio = dio;
final Dio _dio;
static const _baseUrl = '/api/features';
@override
Stream<(List<FeatureName>, Failure?)> listenForChanges() {
// For API repositories, you might implement WebSocket or polling
throw UnimplementedError('Real-time updates not implemented for API');
}
@override
Future<(FeatureName?, Failure?)> getById(String id) async {
try {
final response = await _dio.get('$_baseUrl/$id');
final model = FeatureModel.fromJson(response.data as Map<String, dynamic>);
return (model.toDomain(), null);
} on DioException catch (e) {
logger.e('[ApiFeatureRepository] Failed to get item by id', error: e);
return (null, Failure(message: e.message ?? 'Failed to load item'));
}
}
@override
Future<(FeatureName?, Failure?)> create({
required String property1,
String? property2,
}) async {
try {
final data = {
'property1': property1,
if (property2 != null) 'property2': property2,
};
final response = await _dio.post(_baseUrl, data: data);
final model = FeatureModel.fromJson(response.data as Map<String, dynamic>);
return (model.toDomain(), null);
} on DioException catch (e) {
logger.e('[ApiFeatureRepository] Failed to create item', error: e);
return (null, Failure(message: e.message ?? 'Failed to create item'));
}
}
// ... implement other methods similarly
}3. Key Points for Repository Implementations
Error Handling
- Always wrap operations in try-catch blocks
- Use the logger service for debugging
- Return meaningful error messages
- Handle specific exceptions (DioException, etc.)
Stream Management
- Use broadcast streams for multiple listeners
- Emit current data immediately after subscription
- Clean up stream controllers in dispose methods
- Handle stream errors gracefully
Data Conversion
- Always convert between models and entities
- Use the model's
fromDomain()andtoDomain()methods - Handle JSON serialization errors
Validation
- Validate input parameters before processing
- Return appropriate failure messages for validation errors
- Check for required fields and business rules
4. Code Generation
After creating models, run code generation:
bash
# Generate freezed and JSON serialization code
flutter packages pub run build_runner build
# Or watch for changes during development
flutter packages pub run build_runner watchThe data layer bridges your domain logic with external data sources. Keep it focused on data access and conversion, while maintaining clean separation from business logic.