My early Flutter models often tried to do everything:
class Course {
Course({
required this.id,
required this.title,
});
final String id;
final String title;
factory Course.fromJson(Map<String, dynamic> json) {
return Course(
id: json['_id'] as String,
title: json['course_title'] as String,
);
}
}
The class represented an API response, business data, BLoC state, and UI input at the same time.
This was convenient until the API changed a key, the app needed a computed domain value, or a cached version used a different payload. Transport decisions such as _id and course_title leaked into code that should only care about a course.
The solution I now use is a clear split:
- json_serializable models own JSON.
- Freezed entities own the data used by the application.
- the repository maps between them.
The API model owns the wire format#
The model describes exactly what the server sends:
import 'package:json_annotation/json_annotation.dart';
part 'course_model.g.dart';
@JsonSerializable()
class CourseModel {
const CourseModel({
required this.id,
required this.title,
this.thumbnailUrl,
});
@JsonKey(name: '_id')
final String id;
@JsonKey(name: 'course_title')
final String title;
@JsonKey(name: 'thumbnail')
final String? thumbnailUrl;
factory CourseModel.fromJson(Map<String, dynamic> json) =>
_$CourseModelFromJson(json);
Map<String, dynamic> toJson() => _$CourseModelToJson(this);
}
json_serializable generates the repetitive parsing and serialization code. JsonKey keeps unusual API names at the network boundary.
When the server renames course_title, I update the model mapping. Widgets, BLoCs, and use cases continue using title.
The domain object describes the app#
The application entity has no JSON annotations:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'course.freezed.dart';
@freezed
abstract class Course with _$Course {
const factory Course({
required String id,
required String title,
required Uri? thumbnail,
}) = _Course;
}
Freezed generates immutable value equality, copyWith, readable output, and other data-class behavior.
The entity can use types that make sense inside the app. The API sends a nullable string for the thumbnail. The application can use Uri? after validation.
Map once in the repository#
Add a mapping from the transport model to the domain object:
extension CourseModelMapping on CourseModel {
Course toEntity() {
return Course(
id: id,
title: title.trim(),
thumbnail: thumbnailUrl == null
? null
: Uri.tryParse(thumbnailUrl!),
);
}
}
The service returns models:
Future<List<CourseModel>> getCourses() async {
final response = await client.get('/courses');
final items = response.data['items'] as List<dynamic>;
return items
.map((item) => CourseModel.fromJson(
item as Map<String, dynamic>,
))
.toList();
}
The repository returns entities:
Future<List<Course>> getCourses() async {
final models = await service.getCourses();
return models.map((model) => model.toEntity()).toList();
}
Everything above the repository works with Course, not CourseModel.
Why not put fromJson on the Freezed class?#
Freezed can work with json_serializable in the same class, and that is a reasonable choice for smaller apps or stable local data.
I keep them separate in larger API-driven applications because the two objects change for different reasons:
- the model changes when the API contract changes;
- the entity changes when application behavior changes.
The split also prevents annotations such as JsonKey, default wire values, and API enum strings from spreading into business and UI code.
It is extra code, so I do not pretend it is free. The boundary earns its cost when multiple features or applications share the domain layer, APIs evolve independently, or offline storage uses another representation.
Freezed removes risky manual value code#
Without generated equality, two objects with the same fields are different instances. Without generated copyWith, updates become repetitive:
final updatedCourse = course.copyWith(
title: 'Advanced Flutter',
);
Freezed also works well for states with distinct shapes:
@freezed
sealed class DownloadState with _$DownloadState {
const factory DownloadState.idle() = DownloadIdle;
const factory DownloadState.downloading({
required double progress,
}) = Downloading;
const factory DownloadState.completed({
required String filePath,
}) = DownloadCompleted;
const factory DownloadState.failed({
required String message,
}) = DownloadFailed;
}
Each state contains only the values valid for that state. There is no completed state with a null file path or idle state with stale progress.
json_serializable catches mapping problems early#
Generated parsing is consistent and reviewable. It supports:
- renamed keys with JsonKey;
- nested model serialization;
- default values;
- custom converters;
- enum mappings;
- excluding null values;
- generated toJson methods.
For example, convert a wire string into an enum:
enum CourseStatus {
draft,
published,
archived,
}
@JsonSerializable()
class CourseModel {
const CourseModel({
required this.id,
required this.status,
});
@JsonKey(name: '_id')
final String id;
final CourseStatus status;
}
If the API uses values that differ from the Dart names, use JsonValue or a custom fromJson and toJson converter rather than rewriting the whole factory by hand.
Handle missing and invalid data deliberately#
Code generation cannot decide your product rules. Ask:
- Is a missing title invalid, or should it become “Untitled”?
- Should an unknown enum fail parsing or map to unknown?
- Is an invalid URL ignored or reported?
- Can a numeric value arrive as a string?
Put transport normalization in the model or mapper. Do not scatter defensive JSON checks through widgets.
@JsonKey(defaultValue: '')
final String title;
A default is appropriate only if an empty title is acceptable. Otherwise parsing should fail and the repository should convert that exception into a useful failure.
Test the boundary with real payload shapes#
Model tests should use JSON shaped like the API:
test('maps API keys to a Course entity', () {
final model = CourseModel.fromJson({
'_id': 'course-42',
'course_title': ' Flutter Architecture ',
'thumbnail': 'https://example.com/course.png',
});
final course = model.toEntity();
expect(course.id, 'course-42');
expect(course.title, 'Flutter Architecture');
expect(course.thumbnail?.host, 'example.com');
});
Include missing optional keys, nulls, enum values, and nested objects. These tests protect the boundary where server data becomes trusted application data.
The code-generation workflow#
Both packages use build_runner:
dart run build_runner build --delete-conflicting-outputs
Generated files should never be edited manually. Decide at the repository level whether they are committed or generated in CI, and keep that decision consistent.
Freezed and json_serializable reduced boilerplate for me, but their larger value came from using them at a deliberate boundary. JSON stays in the data layer, and the rest of the app works with clean, immutable domain objects.