Sparround

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, copyWith returns 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. No Color, TextStyle or TimeOfDay here.

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.

dart
// 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-outputs

A domain model with freezed. The `const Product._();` line matters — without it you cannot add your own getters and methods.

Approach`copyWith``==` / `hashCode`JSONIts price
By hand (`final` fields)You write itYou override itYou write itNo 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 itHandled by the `props` listYou write itNo code generation; forgetting to add a new field to `props` creates a silent bug
`freezed`GeneratedGeneratedGenerated 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`.