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:

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:

  • 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" or note.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 did title suddenly 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 onto Note. 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 a NoteEntity, you cannot accidentally modify it. If you want a new version with isFavorite: 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 NoteEntity extends Equatable and lists all fields in props, two NoteEntity instances 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

  • NoteEntity knows 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 an isarId field.
  • Converts to/from NoteEntity via fromEntity/toEntity.

This separation means:

  1. You can run unit tests on your domain logic without any database setup.
  2. If you ever switch from Isar → Hive or Isar → REST API, you only need to modify NoteModel (or build an entirely new NoteDTO), and your domain/use-case/Bloc code remains unchanged.
  3. Your business rules (e.g., “A note’s createdAt must never change”) stay put in NoteEntity, not mixed up with persistence code.

2.3. The Downsides of Manual Boilerplate

So far, we have:

  1. A 30-line NoteEntity with a handwritten copyWith and an Equatable override.
  2. A 30-line NoteModel with fromEntity/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 fromEntity to set lastEditedAt: e.lastEditedAt
  • Update toEntity to read lastEditedAt from NoteModel

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 copyWith for every field (no manual coding).
  • A generated ==/hashCode that does deep equality on lists, maps, etc.
  • Optional integration with json_serializable, so you don’t hand-write any toJson/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.0

2. 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-outputs

You’ll get two generated files:

  • note_entity.freezed.dart
  • note_entity.g.dart

Now What Do You Get?

  • A fully immutable NoteEntity with 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 ==/hashCode that checks all fields (including nested lists/maps if you ever add them).
  • A generated toJson & fromJson so you can do:
final json = original.toJson(); 
final copy = NoteEntity.fromJson(json); 
print(copy == original); // true

Your 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() and NoteEntity.fromJson(…), you could switch to storing a single JSON string field in NoteModel if you want:
final String jsonString = jsonEncode(e.toJson());
  • and NoteModel would just have String noteJson; instead of separate fields. But mapping field-by-field is often clearer.
  • If you add a new field (e.g., lastEditedAt) to NoteEntity, Freezed regenerates serialization automatically. In NoteModel, 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, manual props list.
  • Any field addition/rename requires editing two classes.

3. Freezed + json_serializable for Entity

Pros:

  • Fully immutable, with copyWith and == 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.dart and .g.dart.

Cons:

  • You still need a separate NoteModel for Isar (or Hive, or your chosen persistence). But mapping is simpler because you can rely on NoteEntity.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 new NoteDTO), leaving NoteEntity and 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!