Domain models and immutability (freezed)
A domain model is the app's own vocabulary. It matches the business, not the backend's JSON, not a database table, not the shape of a screen.
A domain model has three properties:
- Immutable — once created it does not change. When a change is needed,
copyWithreturns a new object. - Value equality — two objects with equal fields are equal. This differs from reference equality and directly affects rebuilds.
- Business meaning only — things like
isExpensive,finalPrice,canBeCancelled. NoColor,TextStyleorTimeOfDayhere.
The official Flutter recommendations put immutable models in the highest-priority group: the reason is to guarantee that changes happen where they should. A mutable model lets the UI layer mutate data by accident and breaks unidirectional flow.
// fayl: lib/domain/models/product.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'product.freezed.dart';
@freezed
abstract class Product with _$Product {
// Private boş konstruktor: generasiya olunan kodun sinfi extend etməsinə
// imkan verir — öz getter/metodlarını yazmaq üçün TƏLƏB OLUNUR.
const Product._();
const factory Product({
required String id,
required String title,
required double price, // artıq manatla, çevirmə mapper-də olub
@Default(0) int discountPercent,
@Default(false) bool isArchived,
DateTime? createdAt,
}) = _Product;
// Biznes qaydaları modelin özündə yaşayır — hər ekranda təkrarlanmır.
double get finalPrice => price * (100 - discountPercent) / 100;
bool get hasDiscount => discountPercent > 0;
bool get isPurchasable => !isArchived && price > 0;
}
// İstifadə:
const tea = Product(id: 'p1', title: 'Çay', price: 10, discountPercent: 10);
tea.finalPrice; // 9.0
tea.copyWith(discountPercent: 0); // yeni obyekt, köhnəsi dəyişmir
// Dəyər bərabərliyi: freezed `==` və `hashCode`-u generasiya edir.
const same = Product(id: 'p1', title: 'Çay', price: 10, discountPercent: 10);
assert(tea == same); // true — sahələr eynidir
// Generasiya: dart run build_runner build --delete-conflicting-outputsA domain model with freezed. The `const Product._();` line matters — without it you cannot add your own getters and methods.
| Approach | `copyWith` | `==` / `hashCode` | JSON | Its price |
|---|---|---|---|---|
| By hand (`final` fields) | You write it | You override it | You write it | No code generation, but ~40 lines of boilerplate for a five-field model and a real risk of forgetting a field when adding one |
| `Equatable` | You write it | Handled by the `props` list | You write it | No code generation; forgetting to add a new field to `props` creates a silent bug |
| `freezed` | Generated | Generated | Generated together with `json_serializable` | Needs `build_runner`, and build times grow in large projects — the official recommendation says so explicitly |
Value equality has a very concrete consequence in combination with Riverpod: in Riverpod 3 all `updateShouldNotify` implementations use `==` comparison.
What that means in practice:
- If a model does not override
==(a plain class, reference equality), every new object counts as "changed" and listeners rebuild — even when the fields are identical. - If the model has value equality, a repository returning the same data a second time causes no rebuild.
So immutability plus == is not only a "clean code" matter; it is a measurable performance decision. It shows up especially in list models: comparing List<Product> relies on the elements' ==.
A practical rule: freezed where JSON is involved (DTOs), freezed or Equatable for domain models — if the project already runs build_runner, using freezed for both is one decision fewer.
One important nuance: freezed_annotation is a pure Dart package — its dependencies are collection, json_annotation and meta, with no Flutter dependency. So importing it in the domain layer does not break the dependency rule. The generator itself (freezed, build_runner) stays in dev_dependencies and never ships in the build.
Practice. Pick one domain model in your project and convert it to freezed: copyWith, ==, and one business getter (like finalPrice). Then write a unit test for that getter.
Done means: dart run build_runner build completes without errors, the test is green, and that calculation no longer appears in any widget file.
📚 Sources and documentation
- The freezed packageofficialpub.dev
The source for the syntax: the `abstract`/`sealed` requirement, the `const X._()` rule, `@Default`, JSON integration.
- freezed_annotationofficialpub.dev
The fact that matters for importing it in the domain layer: this package has no Flutter dependency.
- Dart: equality and `hashCode`officialdart.dev
Dart's design guidelines — including the rule that `==` and `hashCode` are overridden together.
- Architecture recommendations: immutable modelsofficialdocs.flutter.dev
Why immutable models are a top-priority recommendation, and the official stance on `freezed`/`built_value`.