Why you should use Entities instead of plain Classes in Clean Architecture.
Imagine you’re building a simple note-taking feature. You need to store:
Imagine you’re building a simple note-taking feature. You need to store:
id(string)title(string)content(string)createdAt(DateTime)isFavorite(bool)
So you write the “obvious” Dart class:
class Note {
String id;
String title;
String content;
DateTime createdAt;
bool isFavorite;
Note({
required this.id,
required this.title,
required this.content,
required this.createdAt,
this.isFavorite = false,
});
}On the surface, there’s nothing wrong! You can do:
final note = Note(
id: 'n1',
title: 'Buy Groceries',
content: 'Milk, Bread, Eggs...',
createdAt: DateTime.now(),
);
// Later, mark it as favorite:
note.isFavorite = true;But Very Soon… Problems Appear
1. Mutability Everywhere
- Any part of your code can do
note.title = "New"ornote.content = null(if you allowed it). - In small apps that’s okay — but once your UI has multiple widgets reading/writing the same
Note, you’ll start chasing “Why didtitlesuddenly change?” bugs.
2. No Value-Based Equality
- Suppose you call a function
void printNoteIfNew(Note incoming) { … }. You want to check “If this note is different from the one I already have, print it.” If you write:
if (incoming == existing) {
// never prints, because == just checks identity
}- Two different
Note(id: 'n1', title: 'x', ...)instances will not be equal, because Dart’s default==compares object identity, not field values.
3. Persistence Coupling Temptation
- Next you decide to save notes in a local database (say, Isar). You might sprinkle
@Collection()or annotations right ontoNote. Suddenly, your “pure business logic” type is tied to Isar. - If you later want to swap to Hive, SQLite, or a remote API, you now have to remove Isar imports from everywhere.
In short, this simple class is easy at first — but as soon as you add state management, testing, and persistence, you’ll wish you’d started with a cleaner structure. Let’s refactor.
2. Create a Plain-Dart Entity + a Persistence Model (Manual copyWith + Equatable)
2.1. Define a Pure “NoteEntity” in Your Domain Layer(recommended) or as per your need
First, imagine the “domain” (business-logic) layer has absolutely no knowledge of Isar, JSON, or any database.
It only needs a “NoteEntity” to represent a note in memory, with helpful methods (like copyWith) and value-based equality using Equatable.
// lib/src/domain/entities/note_entity.dart
import 'package:equatable/equatable.dart';
class NoteEntity extends Equatable {
final String id;
final String title;
final String content;
final DateTime createdAt;
final bool isFavorite;
const NoteEntity({
required this.id,
required this.title,
required this.content,
required this.createdAt,
this.isFavorite = false,
});
/// Helper: create a new entity with one field changed
NoteEntity copyWith({
String? id,
String? title,
String? content,
DateTime? createdAt,
bool? isFavorite,
}) {
return NoteEntity(
id: id ?? this.id,
title: title ?? this.title,
content: content ?? this.content,
createdAt: createdAt ?? this.createdAt,
isFavorite: isFavorite ?? this.isFavorite,
);
}
/// Equatable: allows value-based comparison
@override
List<Object?> get props => [
id,
title,
content,
createdAt,
isFavorite,
];
}Why Is This Better?
1. Immutability
- All fields are
final. Once you create aNoteEntity, you cannot accidentally modify it. If you want a new version withisFavorite: true, you call:
final updated = oldNote.copyWith(isFavorite: true);- That ensures you never have “mid-frame” mutations that confuse UI rebuilds.
2. Value-Based Equality
- Because
NoteEntityextendsEquatableand lists all fields inprops, twoNoteEntityinstances with identical values will be equal:
final a = NoteEntity(
id: 'n1',
title: 'Test',
content: 'Hello',
createdAt: someDate,
);
final b = NoteEntity(
id: 'n1',
title: 'Test',
content: 'Hello',
createdAt: someDate,
);
print(a == b); // true- This is critical in tests and in state-management layers (Bloc, Riverpod, etc.) so that UI updates only when truly necessary.
3. No Persistence Details
NoteEntityknows nothing about Isar, Hive, SQLite, or JSON. It is 100% focused on “Here is the business object Note.”- That makes unit tests for your use-cases super easy — no need to spin up a database just to construct a
NoteEntity.
2.2. Define a “NoteModel” for Persistence (Data Layer)
Next, create a separate class — NoteModel—in your Data Layer. This one can include Isar annotations (or Hive, or whatever) because it’s solely responsible for how we store/retrieve notes.
// lib/src/data/models/note_model.dart
import 'package:equatable/equatable.dart';
import 'package:isar/isar.dart';
import '../../domain/entities/note_entity.dart';
part 'note_model.g.dart';
@Collection()
class NoteModel extends Equatable {
// Primary key for Isar (auto-increment)
Id isarId = Isar.autoIncrement;
@Index()
final String id;
final String title;
final String content;
@Index()
final DateTime createdAt;
@Index()
final bool isFavorite;
NoteModel({
this.isarId = Isar.autoIncrement,
required this.id,
required this.title,
required this.content,
required this.createdAt,
required this.isFavorite,
});
@override
List<Object?> get props => [
isarId,
id,
title,
content,
createdAt,
isFavorite,
];
// Convert a domain entity into this Model for saving to Isar
factory NoteModel.fromEntity(NoteEntity e) {
return NoteModel(
id: e.id,
title: e.title,
content: e.content,
createdAt: e.createdAt,
isFavorite: e.isFavorite,
);
}
// Convert this Model back into a pure domain entity
NoteEntity toEntity() {
return NoteEntity(
id: id,
title: title,
content: content,
createdAt: createdAt,
isFavorite: isFavorite,
);
}
}Why Keep Entity & Model Separate?
Domain Layer (NoteEntity)
- Contains business-logic helpers (
copyWith,==), validation, computed properties, etc. - Totally unaware of how or where it’s stored.
Data Layer (NoteModel)
- Knows exactly how to persist (Isar in this example).
- Contains annotations like
@Collection(),@Index(), and anisarIdfield. - Converts to/from
NoteEntityviafromEntity/toEntity.
This separation means:
- You can run unit tests on your domain logic without any database setup.
- If you ever switch from Isar → Hive or Isar → REST API, you only need to modify
NoteModel(or build an entirely newNoteDTO), and your domain/use-case/Bloc code remains unchanged. - Your business rules (e.g., “A note’s
createdAtmust never change”) stay put inNoteEntity, not mixed up with persistence code.
2.3. The Downsides of Manual Boilerplate
So far, we have:
- A 30-line
NoteEntitywith a handwrittencopyWithand anEquatableoverride. - A 30-line
NoteModelwithfromEntity/toEntity.
Every time you add or rename a field, you must edit both classes manually, plus update both constructors, props, and the mapping logic. For example, if you decide that a Note should also have a lastEditedAt timestamp:
In NoteEntity:
- Add
final DateTime? lastEditedAt; - Update constructor,
copyWith,props.
In NoteModel:
- Add
final DateTime? lastEditedAt;with@Index() - Update constructor signature.
- Update
fromEntityto setlastEditedAt: e.lastEditedAt - Update
toEntityto readlastEditedAtfromNoteModel
That’s a lot of “find & replace,” and it’s easy to forget something. Plus, manual JSON conversion (when your Model needs to persist nested objects or maps) is equally tedious.
Time to automate.
3. Embrace Freezed (+ json_serializable) to Eliminate Boilerplate
3.1. What Does Freezed Give Us?
- Immutable Data Classes by default (all fields are
final). - A generated
copyWithfor every field (no manual coding). - A generated
==/hashCodethat does deep equality on lists, maps, etc. - Optional integration with
json_serializable, so you don’t hand-write anytoJson/fromJson. - (Bonus) Support for Union/Sealed classes if you want a “Result” type in the future:
@freezed
class Result<T> with _$Result<T> {
const factory Result.data(T value) = Data<T>;
const factory Result.error(String message) = Error<T>;
}In short, you write a tiny annotated class and Freezed generates hundreds of lines of boilerplate for you.
3.2. Setting Up Freezed + json_serializable
1. Add to pubspec.yaml:
dependencies:
freezed_annotation: ^2.0.0
json_annotation: ^4.0.1
dev_dependencies:
build_runner: ^2.0.0
freezed: ^2.0.0
json_serializable: ^6.0.02. Create a Freezed “NoteEntity”
In lib/src/domain/entities/note_entity.dart:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'note_entity.freezed.dart';
part 'note_entity.g.dart'; // for JSON serialization
@freezed
class NoteEntity with _$NoteEntity {
const factory NoteEntity({
required String id,
required String title,
required String content,
required DateTime createdAt,
@Default(false) bool isFavorite,
// Later, if you want lastEditedAt, you just add it here:
// DateTime? lastEditedAt,
}) = _NoteEntity;
factory NoteEntity.fromJson(Map<String, dynamic> json) =>
_$NoteEntityFromJson(json);
}3. Run Code Generation
flutter pub run build_runner build --delete-conflicting-outputsYou’ll get two generated files:
note_entity.freezed.dartnote_entity.g.dart
Now What Do You Get?
- A fully immutable
NoteEntitywith a private constructor_NoteEntity. - A
copyWith({ ... })method that returns a new instance with any subset of fields changed:
final original = NoteEntity(
id: 'n1',
title: 'Hello',
content: '...',
createdAt: someDate,
);
final fav = original.copyWith(isFavorite: true);- A correct
==/hashCodethat checks all fields (including nested lists/maps if you ever add them). - A generated
toJson&fromJsonso you can do:
final json = original.toJson();
final copy = NoteEntity.fromJson(json);
print(copy == original); // trueYour Domain File Is Now Tiny
Instead of ~30 lines of manual code, you now have ~15 lines of annotated code. Everything else (boilerplate) lives in generated files you never edit by hand.
3.3. Updating the Data Layer “NoteModel”
You still need a persistence (Isar) model. But mapping becomes easier because NoteEntity.toJson() gives you a full Map<String, dynamic>. You can store individual fields or stash the entire JSON string—whichever you prefer.
Here’s one way to keep a separate NoteModel:
// lib/src/data/models/note_model.dart
import 'package:equatable/equatable.dart';
import 'package:isar/isar.dart';
import 'dart:convert';
import '../../domain/entities/note_entity.dart';
part 'note_model.g.dart';
@Collection()
class NoteModel extends Equatable {
Id isarId = Isar.autoIncrement;
@Index()
final String id;
final String title;
final String content;
@Index()
final DateTime createdAt;
@Index()
final bool isFavorite;
NoteModel({
this.isarId = Isar.autoIncrement,
required this.id,
required this.title,
required this.content,
required this.createdAt,
required this.isFavorite,
});
@override
List<Object?> get props => [
isarId,
id,
title,
content,
createdAt,
isFavorite,
];
/// Build a NoteModel from a NoteEntity:
factory NoteModel.fromEntity(NoteEntity e) {
// Because Freezed generated a toJson, we could just do:
// final jsonString = jsonEncode(e.toJson());
// But here we explicitly map fields one by one:
return NoteModel(
id: e.id,
title: e.title,
content: e.content,
createdAt: e.createdAt,
isFavorite: e.isFavorite,
);
}
/// Convert this Model back into the pure domain entity
NoteEntity toEntity() {
return NoteEntity(
id: id,
title: title,
content: content,
createdAt: createdAt,
isFavorite: isFavorite,
);
}
}Key Points:
- You still keep two separate classes —
NoteEntity(domain) vs.NoteModel(data). - Because Freezed already gave you
e.toJson()andNoteEntity.fromJson(…), you could switch to storing a single JSON string field inNoteModelif you want:
final String jsonString = jsonEncode(e.toJson());- and
NoteModelwould just haveString noteJson;instead of separate fields. But mapping field-by-field is often clearer. - If you add a new field (e.g.,
lastEditedAt) toNoteEntity, Freezed regenerates serialization automatically. InNoteModel, you only add one line for that new field—much less error‐prone than before.
4. Why This “Clean” Structure Pays Off
Let’s summarize the advantages of each step:
1. Naïve Single Class
Pros: Quick to write.
Cons:
- Mutable everywhere → hard to track state changes.
- No value equality → “why isn’t my Bloc/UI updating?”
- Ties your business object to persistence → painful if you switch DBs.
2. Manual Entity + Model + copyWith + Equatable
Pros:
- Domain (
NoteEntity) is immutable and testable. - Data (
NoteModel) is isolated in the data layer (Isar annotations). - Clear mapping between persistence and business object.
Cons:
- Lots of boilerplate: manual
copyWith, manual mapping, manualpropslist. - Any field addition/rename requires editing two classes.
3. Freezed + json_serializable for Entity
Pros:
- Fully immutable, with
copyWithand==auto-generated. - JSON serialization auto-generated — no manual
toJson/fromJson. - If you add a new field, you annotate it once in the Freezed class and re-run codegen — everything updates automatically.
- Your domain file shrinks dramatically; generated code lives in
.freezed.dartand.g.dart.
Cons:
- You still need a separate
NoteModelfor Isar (or Hive, or your chosen persistence). But mapping is simpler because you can rely onNoteEntity.toJson()if you want.
Overall, by the time you’ve refactored to Freezed, you’ve eliminated most of the manual, error-prone stuff, and you’ve kept a clean boundary between “business logic” and “data/persistence.” This leads to:
Easier Testing
- Domain tests don’t depend on any DB.
- You can mock repositories in use-case tests.
- You can mock use-cases in your Bloc tests.
Easier Maintenance
- Add or rename fields once in the Freezed class; codegen handles the rest.
- If you decide to switch from Isar → Hive or SQLite, you only update your
NoteModel(or create a newNoteDTO), leavingNoteEntityand the rest of your code untouched.
Clear Separation of Concerns
- Domain knows only about “Notes as pure Dart objects.”
- Data knows only about “how to persist Notes.”
- Presentation (Bloc/UI) knows only about “how to call use cases and display the NoteEntity.”
6. Key Takeaways & Best Practices
1. Start Simple, Refactor Quickly
- It’s fine to begin with a naïve Dart class during rapid prototyping.
- As soon as you add any state management or persistence, extract an Entity in your domain layer.
2. Keep Domain Pure
- Domain Entities (with Freezed) know only about the business object. No DB or JSON code here.
- If you need validation (e.g. “title cannot be empty”), put that in the domain Entity or a separate Value Object.
3. Isolate Persistence in Data Layer
- Your Model (Isar/Hive/SQLite/…) lives in the data layer. It’s responsible for mapping to/from the Entity.
- When you switch databases, only the Model and Repository implementation change.
4. Use Freezed (+ json_serializable) for Entities
- Cut down on handwritten
copyWith, manual==, and JSON parsing. - Adding a new field is one small change in the Freezed class, then run codegen.
5. Repository & Use Case Layers
- The domain only knows the abstract repository interface.
- The data layer concretely implements that interface.
- Use cases coordinate business rules and call the repository.
- Presentation (Bloc/Cubit/ViewModel) only calls use cases and consumes Entities.
6. Testing Is Simple
- Test Entities and Value Objects in isolation (pure Dart, no DB).
- Test Use Cases with mocked repositories (no real DB).
- Test Blocs with mocked use cases (no real DB or HTTP).
- If needed, write a small integration test that brings up an in-memory Isar instance to validate your repository.
7. Future-Proofing
- Need to add a new field? Freezed handles it.
- Need to switch from Isar → Hive or Isar → remote API? Just write a new Model or adapt your existing Model; your Entity, Use Cases, and Bloc remain unchanged.
A Final Word
By swapping out the complex “Collection” example for this minimal “Note” example, you should see the same principles at work — only in fewer lines of code. The trajectory remains:
Naïve single class → 2. Split into Entity + Model (manual) → 3. Freezed + JSON automation → 4. Repository/Use-Case/Bloc layering.
Each step improves immutability, testability, separation of concerns, and maintainability. Once you internalize this pattern, you can apply it to any domain object — whether it’s a Note, a Task, a Collection, a User Profile, or anything else you dream up for your app.
Happy coding!