| |

Dart 69 🎯 Clean Architecture in Pure Dart: Entities, Use Cases, and Repositories

⏱️ Estimated read: 13 min | |

Clean Architecture is a way of organizing code so that the business logic—the rules that define what your application actually does—is isolated from the tools and frameworks that deliver it. In Dart, this matters because the same domain logic might need to run in a Flutter app, a command-line tool, a server, or a test suite. If the business rules are tangled up with http calls, json.decode, or widget trees, none of those transitions are possible without a rewrite. The architecture draws a boundary: the domain layer knows nothing about the outside world, and the data layer knows nothing about the UI.

The three core concepts in this boundary are entities, use cases, and repositories. An entity is a pure Dart object representing a business concept—a User, an Order, a Ranking—with no JSON annotations, no database concerns, and no framework dependencies . A use case is a single operation the application can perform, like Login or GetRankings, and it contains the business rules that govern that operation . A repository is an abstract contract—an interface—that says “I need to be able to fetch rankings” without specifying whether that data comes from a REST API, a local database, or a mock in a test .

This chapter covers the pure Dart side of Clean Architecture: how to define entities, how to write use cases that depend only on abstractions, and how the repository interface enforces the dependency rule. The data layer implementation—models, data sources, and repository implementations—belongs to the layer that depends on the domain, not the other way around.

Key point: The domain layer contains entities, use case classes, and repository interfaces, and it must not import anything from the data or presentation layers; the data layer implements the repository interfaces and converts external data into domain entities, so dependencies always point inward.


Why Clean Architecture exists in Dart

The framework entanglement problem. Without a boundary, business logic spreads into widgets, API clients, and state managers. A login rule becomes a method on a LoginController that imports FirebaseAuth, reads SharedPreferences, and shows a SnackBar. When you want to test the rule, you need Firebase. When you want to reuse the rule in a CLI, you need to rebuild it. Clean Architecture separates the rule from the delivery mechanism so the rule can be tested and reused in isolation .

The testability problem. A use case that depends on an abstract repository can be tested with a fake repository that returns a fixed list of entities. No network, no database, no asynchronous setup beyond the fake’s Future.value. The test runs in milliseconds and verifies the business rule, not the HTTP client . This is the practical payoff of the dependency rule: the domain layer’s tests do not need the data layer to exist.

The change problem. APIs change. Firebase gets replaced with a REST backend. A local cache is added. When the repository interface is stable, those changes are confined to the data layer. The use case does not change because it never knew how the data was fetched . This is the Open/Closed Principle in practice: the domain is open to new data sources but closed to modification when the data source changes.

The naming and boundary problem. Without conventions, the boundary erodes. A developer imports RankingModel into a use case because it has a convenient fromJson method, and suddenly the domain layer depends on the data layer. The conventions that keep this from happening are structural: entities end with Entity, models end with Model, repository interfaces live in domain/repositories/, and implementations live in data/repositories/ . The folder structure is not decoration—it is the enforcement mechanism.


a. Entities: pure business objects

An entity is a Dart class that represents a concept in the business domain. It has fields, constructors, and—optionally—business logic as methods or extension methods. It does not have fromJson, toJson, toMap, or any annotation from json_serializable or freezed unless that annotation is used purely for immutability and equality without serialization concerns .

A minimal entity:

class Ranking {
  final String id;
  final DateTime startTime;
  final DateTime endTime;
  final int attendeeCount;

  const Ranking({
    required this.id,
    required this.startTime,
    required this.endTime,
    required this.attendeeCount,
  });
}

The entity has no fromJson. It has no toEntity or toModel. It is a plain value object. Business logic can live on the entity as methods or extensions, but it must be logic that is true regardless of where the data came from:

extension RankingX on Ranking {
  Duration get duration => endTime.difference(startTime);
  bool get isHighAttendance => attendeeCount > 5;
  bool get isValidTimeRange => endTime.isAfter(startTime);
}

The isHighAttendance check is a business rule. It does not care whether the Ranking came from an API, a database, or a test fixture. It is part of the domain .

The entity can use Equatable or Freezed for value equality, but this is an implementation choice, not a requirement. If Freezed is used, the fromJson factory should live on the model in the data layer, not on the entity . The entity stays pure.


b. Repository interfaces: contracts without implementation

A repository interface is an abstract class in the domain layer that defines the operations the domain needs. It returns domain entities, not models or JSON. It uses Future or Stream for asynchronous operations, and it can use Either<Failure, T> from fpdart or dartz to represent success or failure without throwing exceptions .

abstract class RankingRepository {
  Future<List<Ranking>> getRankings();
  Future<Ranking> getRankingById(String id);
  Future<Ranking> createRanking(Ranking ranking);
  Future<void> deleteRanking(String id);
}

The interface is the dependency inversion point. The use case depends on this abstraction. The data layer provides an implementation that depends on the same abstraction . The domain layer never imports the implementation. The implementation imports the domain layer to know what interface it is implementing and what entity it is returning.

The Either pattern is optional but common in Dart Clean Architecture. It makes failure explicit in the type system and avoids the ambiguity of null or thrown exceptions . A repository interface with Either:

abstract class RankingRepository {
  Future<Either<Failure, List<Ranking>>> getRankings();
}

The Failure class is also in the domain layer—or in a shared core module that the domain depends on—because it is a domain concept, not a data-layer exception .


c. Use cases: single operations with business rules

A use case is a class with a call method that represents one operation the application can perform. It receives the repository interface through its constructor and uses it to fetch or persist entities. It applies business rules that belong to that operation and returns the result .

class GetRankingsUseCase {
  final RankingRepository repository;

  GetRankingsUseCase(this.repository);

  Future<List<Ranking>> call({bool onlyHighAttendance = false}) async {
    final rankings = await repository.getRankings();

    if (onlyHighAttendance) {
      return rankings.where((r) => r.isHighAttendance).toList();
    }

    return rankings;
  }
}

The onlyHighAttendance filter is a business rule that belongs to the GetRankings operation. It uses the isHighAttendance extension on the entity. The use case does not know how getRankings fetches its data. It only knows that the repository will return a list of Ranking objects .

The constructor injection is deliberate. The use case does not create its own repository. It receives one, which means a test can pass a fake implementation, and a production app can pass the real one. This is the dependency inversion principle in its simplest form .

A use case with a parameter object is often clearer than one with many positional arguments:

class GetRankingsParams {
  final bool onlyHighAttendance;
  const GetRankingsParams({this.onlyHighAttendance = false});
}

The use case’s call method accepts the params object and returns Future<List<Ranking>> . For operations that return no value, the return type is Future<void>. For operations that can fail, the return type is Future<Either<Failure, T>>.

The domain layer’s dependency rule is strict: a use case file must not import package:http, package:firebase_auth, dart:io, or any file from data/. If it does, the boundary is broken, and the architecture is no longer Clean .


Complete Example Session

The following session builds the domain layer for a ranking feature: an entity, a repository interface, and two use cases.

// ============================================
// PART 1: THE ENTITY
// ============================================
class Ranking {
  final String id;
  final DateTime startTime;
  final DateTime endTime;
  final int attendeeCount;

  const Ranking({
    required this.id,
    required this.startTime,
    required this.endTime,
    required this.attendeeCount,
  });
}

// ============================================
// PART 2: ENTITY BUSINESS LOGIC
// ============================================
extension RankingX on Ranking {
  Duration get duration => endTime.difference(startTime);
  bool get isHighAttendance => attendeeCount > 5;
  bool get isValidTimeRange => endTime.isAfter(startTime);
}

// ============================================
// PART 3: FAILURE TYPE
// ============================================
abstract class Failure {
  final String message;
  const Failure(this.message);
}

class ServerFailure extends Failure {
  const ServerFailure(super.message);
}

class CacheFailure extends Failure {
  const CacheFailure(super.message);
}

// ============================================
// PART 4: REPOSITORY INTERFACE
// ============================================
abstract class RankingRepository {
  Future<List<Ranking>> getRankings();
  Future<Ranking> getRankingById(String id);
  Future<Ranking> createRanking(Ranking ranking);
  Future<void> deleteRanking(String id);
}

// ============================================
// PART 5: GET RANKINGS USE CASE
// ============================================
class GetRankingsUseCase {
  final RankingRepository repository;

  GetRankingsUseCase(this.repository);

  Future<List<Ranking>> call({bool onlyHighAttendance = false}) async {
    final rankings = await repository.getRankings();

    if (onlyHighAttendance) {
      return rankings.where((r) => r.isHighAttendance).toList();
    }

    return rankings;
  }
}

// ============================================
// PART 6: CREATE RANKING USE CASE
// ============================================
class CreateRankingUseCase {
  final RankingRepository repository;

  CreateRankingUseCase(this.repository);

  Future<Ranking> call(Ranking ranking) async {
    if (!ranking.isValidTimeRange) {
      throw ArgumentError('End time must be after start time');
    }

    return repository.createRanking(ranking);
  }
}

// ============================================
// PART 7: FAKE REPOSITORY FOR TESTING
// ============================================
class FakeRankingRepository implements RankingRepository {
  final List<Ranking> _rankings = [];

  @override
  Future<List<Ranking>> getRankings() async => List.unmodifiable(_rankings);

  @override
  Future<Ranking> getRankingById(String id) async =>
      _rankings.firstWhere((r) => r.id == id);

  @override
  Future<Ranking> createRanking(Ranking ranking) async {
    _rankings.add(ranking);
    return ranking;
  }

  @override
  Future<void> deleteRanking(String id) async {
    _rankings.removeWhere((r) => r.id == id);
  }
}

// ============================================
// PART 8: TESTING THE USE CASE
// ============================================
void main() async {
  final repository = FakeRankingRepository();
  final getRankings = GetRankingsUseCase(repository);
  final createRanking = CreateRankingUseCase(repository);

  await createRanking(Ranking(
    id: '1',
    startTime: DateTime(2026, 1, 1, 9),
    endTime: DateTime(2026, 1, 1, 10),
    attendeeCount: 10,
  ));

  await createRanking(Ranking(
    id: '2',
    startTime: DateTime(2026, 1, 2, 9),
    endTime: DateTime(2026, 1, 2, 10),
    attendeeCount: 3,
  ));

  final all = await getRankings();
  print(all.length); // 2

  final high = await getRankings(onlyHighAttendance: true);
  print(high.length); // 1
}

The eight parts cover the entity, its business logic extension, the failure type, the repository interface, two use cases with different rules, a fake repository for testing, and a test that verifies the filtering rule. What the session demonstrates is that the domain layer is self-contained: the use cases depend only on the repository interface and the entity, the fake repository proves the logic without any external dependency, and the business rules are testable in isolation.


Quick Reference

Domain Layer Components

ComponentLocationDepends On
Entitydomain/entities/Nothing (pure Dart)
Failuredomain/ or core/Nothing
Repository interfacedomain/repositories/Entity
Use casedomain/usecases/Repository interface, Entity

Dependency Rule

LayerMay Depend On
DomainNothing (except core)
DataDomain
PresentationDomain

Entity vs Model

AspectEntityModel
LocationDomainData
SerializationNo fromJson/toJsonHas fromJson/toJson
PurityNo framework importsMay import dart:convert
PurposeBusiness conceptData transfer

Use Case Patterns

PatternSignature
No paramsFuture<T> call()
With paramsFuture<T> call(Params p)
With failureFuture<Either<Failure, T>> call(Params p)
No returnFuture<void> call(Params p)

Best Practices

✅ Do This:

// domain/entities/ranking.dart
class Ranking { final String id; ... }                    // ✅ pure entity

// domain/repositories/ranking_repository.dart
abstract class RankingRepository { Future<List<Ranking>> getRankings(); } // ✅ interface

// domain/usecases/get_rankings_usecase.dart
class GetRankingsUseCase { final RankingRepository repo; } // ✅ depends on abstraction

❌ Don’t Do This:

// domain/entities/ranking.dart
import 'package:json_annotation/json_annotation.dart';     // ❌ framework in domain
@JsonSerializable() class Ranking { ... }

// domain/usecases/get_rankings_usecase.dart
import '../../data/repositories/ranking_repository_impl.dart'; // ❌ depends on data

Common Pitfalls

PitfallWhy It HappensFix
Entity has fromJsonConvenienceMove serialization to model in data layer
Use case imports data layerNeeded implementation detailDepend on interface, inject implementation
Repository returns modelModel was already availableReturn entity; convert in data layer
Domain imports FlutterUsed Color or WidgetKeep UI concerns in presentation
Testing requires HTTPUse case instantiates repositoryInject repository through constructor

Real-World Examples

1. Entity with business logic

class Order {
  final double total;
  bool get isLargeOrder => total > 1000;
}

2. Repository interface

abstract class UserRepository {
  Future<User> getUser(String id);
}

3. Use case with filter

class GetActiveUsers {
  Future<List<User>> call() => repo.getUsers().then((u) => u.where((x) => x.active).toList());
}

4. Use case with validation

if (!ranking.isValidTimeRange) throw ArgumentError(...);

5. Fake repository for tests

class FakeRepo implements RankingRepository { ... }

6. Use case with params object

class GetRankingsParams { final bool highOnly; }

7. Failure type

class NotFoundFailure extends Failure { const NotFoundFailure() : super('Not found'); }

8. Either return type

Future<Either<Failure, List<Ranking>>> getRankings();

9. Dependency injection

GetRankingsUseCase(RankingRepositoryImpl(dataSource))

10. Test without network

final useCase = GetRankingsUseCase(FakeRankingRepository());

Visual

The Dependency Rule

┌──────────────────────────────────────────────┐
│  PRESENTATION                                │
│  (UI, state management)                      │
│         │                                    │
│         │ depends on                         │
│         ▼                                    │
│  DOMAIN                                      │
│  (entities, use cases, repository interfaces)│
│         ▲                                    │
│         │ implements                         │
│         │                                    │
│  DATA                                        │
│  (models, data sources, repository impls)    │
│                                              │
│  Dependencies point inward.                  │
│  Domain knows nothing outward.               │
└──────────────────────────────────────────────┘

Entity vs Model

┌──────────────────────────────────────────────┐
│  DOMAIN ENTITY                               │
│  class Ranking {                             │
│    final String id;                          │
│    final DateTime startTime;                 │
│    // no fromJson, no toJson                │
│  }                                           │
│         │                                    │
│         │ data layer converts               │
│         ▼                                    │
│  DATA MODEL                                  │
│  class RankingModel extends Ranking {        │
│    factory RankingModel.fromJson(...)        │
│    Map<String, dynamic> toJson()             │
│  }                                           │
└──────────────────────────────────────────────┘

Use Case Flow

┌──────────────────────────────────────────────┐
│  UI calls: getRankings(onlyHighAttendance: true) │
│         │                                    │
│         ▼                                    │
│  GetRankingsUseCase.call()                   │
│         │                                    │
│         ▼                                    │
│  repository.getRankings()                    │
│         │                                    │
│         ▼                                    │
│  Returns List<Ranking>                       │
│         │                                    │
│         ▼                                    │
│  Filter: where(r => r.isHighAttendance)      │
│         │                                    │
│         ▼                                    │
│  Return filtered list                        │
│                                              │
│  Use case applies the business rule.         │
│  Repository handles the data.                │
└──────────────────────────────────────────────┘

Testing with Fake Repository

┌──────────────────────────────────────────────┐
│  TEST                                        │
│  final repo = FakeRankingRepository();       │
│  final useCase = GetRankingsUseCase(repo);   │
│         │                                    │
│         ▼                                    │
│  await useCase(onlyHighAttendance: true)     │
│         │                                    │
│         ▼                                    │
│  Fake returns [Ranking(attendeeCount: 10)]   │
│         │                                    │
│         ▼                                    │
│  Use case filters → returns [that ranking]   │
│         │                                    │
│         ▼                                    │
│  expect(result.length, 1) // passes          │
│                                              │
│  No HTTP. No database. No Flutter.           │
└──────────────────────────────────────────────┘

Summary

ItemValue
EntityPure business object in domain/entities/
Repository interfaceAbstract contract in domain/repositories/
Use caseSingle operation in domain/usecases/
Dependency ruleDomain depends on nothing outward
Entity vs modelEntity has no serialization; model does
Repository return typeDomain entities, not models
Use case injectionConstructor receives repository interface
TestingFake repository implements interface

Key takeaways:

  • Entities are pure. No fromJson, no framework imports, no database concerns. Business logic can live on the entity as methods or extensions .
  • Repository interfaces live in the domain. They define what the domain needs, not how it is fetched. The data layer implements them .
  • Use cases are single operations. They receive the repository through the constructor, apply business rules, and return entities .
  • The dependency rule is strict. Domain must not import data or presentation. If a use case imports a model or an HTTP client, the boundary is broken .
  • Testing is the payoff. A fake repository lets you test business rules without network, database, or Flutter .
  • Models belong to the data layer. They extend or convert to entities and handle fromJson/toJson. The domain never sees them .

Remember: Clean Architecture in pure Dart is about drawing a line and keeping it. The domain layer—entities, repository interfaces, and use cases—knows nothing about the outside world. It does not know whether data comes from a REST API, Firebase, SQLite, or a test fixture. The data layer knows about the domain because it implements the interfaces and returns the entities, but the dependency points inward. When you write a use case, inject the repository interface, apply the rule, and return the entity. When you write an entity, keep it pure. The moment you import json_annotation into the domain, you have lost the boundary, and the architecture is no longer clean.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!