DEV Community

Cover image for Be Pragmatic, Not Dogmatic: What I Skip in Clean Architecture (and What I Keep)
mt-laid
mt-laid

Posted on

Be Pragmatic, Not Dogmatic: What I Skip in Clean Architecture (and What I Keep)

When I started building Flutter apps seriously, I followed Clean Architecture to the letter. Every feature got an entity, a model, a DTO, a use case, a repository interface in the domain layer, and its implementation in the data layer. If someone asked me why, my answer was: "Because that's Clean Architecture."

That's not a reason. It took me a while to see it.

This article isn't against Clean Architecture. The principles behind it are good: separate concerns, keep business logic independent, make code testable. My point is that the folder structure from the tutorials is one way to achieve those principles, not the principles themselves, and in many features you can reach the same goals with much less code.

The four decisions I walk through below (entities and models, use cases, repository interfaces, and Cubit vs Bloc) are just examples. They're here to make one way of thinking concrete, and that way of thinking applies far beyond them: to state management, folder structure, how much you test, even which packages you add.

The question I ask now

Before adding any layer or class, I ask:

Can I explain the purpose of this in a few seconds?

If I can't, I probably don't need it or at least i don't know why i need it. A layer that only copies data from one class to another, or only forwards a call to the next layer, adds files and noise without adding business value. And noise has a real cost: more code to read, to maintain, and to explain to new teammates.

The same feature, two ways

Let's take a simple feature: showing a user profile loaded from an API.

The "by the book" version

features/profile/
├── domain/
│   ├── entities/user.dart
│   ├── repositories/user_repository.dart      (interface)
│   └── usecases/get_user.dart
├── data/
│   ├── models/user_model.dart
│   ├── datasources/user_remote_data_source.dart
│   └── repositories/user_repository_impl.dart
└── presentation/
    └── cubit/
        ├── user_cubit.dart
        └── user_state.dart
Enter fullscreen mode Exit fullscreen mode

That's 8 files for one screen that loads one object. Here's what some of them contain:

// domain/entities/user.dart
class User {
  const User({required this.id, required this.name, required this.email});
  final int id;
  final String name;
  final String email;
}
Enter fullscreen mode Exit fullscreen mode
// data/models/user_model.dart
class UserModel extends User {
  const UserModel({
    required super.id,
    required super.name,
    required super.email,
  });

  factory UserModel.fromJson(Map<String, dynamic> json) => UserModel(
        id: json['id'] as int,
        name: json['name'] as String,
        email: json['email'] as String,
      );
}
Enter fullscreen mode Exit fullscreen mode
// domain/usecases/get_user.dart
class GetUser {
  const GetUser(this._repository);
  final UserRepository _repository;

  Future<User> call(int id) => _repository.getUser(id);
}
Enter fullscreen mode Exit fullscreen mode

Look at the entity and the model: they have exactly the same fields. And look at the use case: the whole class forwards one call. Neither adds any behavior. The Cubit then calls the use case, which calls the repository interface, which is implemented by a class that calls a data source.

The lean version

Same layers, same folders. Three files are gone:

features/profile/
├── domain/
│   ├── entities/
│   │   └── user.dart
│   └── repositories/
│       └── user_repository.dart       (the contract)
├── data/
│   └── repositories/
│       └── user_repository_impl.dart
└── presentation/
    └── cubit/
        ├── user_cubit.dart
        └── user_state.dart
Enter fullscreen mode Exit fullscreen mode

What was removed:

  • user_model.dart: merged into the entity, since they had the same fields.
  • get_user.dart: the use case only forwarded a call.
  • user_remote_data_source.dart: the repository implementation now makes the HTTP call itself.
// domain/entities/user.dart
class User {
  const User({required this.id, required this.name, required this.email});

  factory User.fromJson(Map<String, dynamic> json) => User(
        id: json['id'] as int,
        name: json['name'] as String,
        email: json['email'] as String,
      );

  final int id;
  final String name;
  final String email;
}
Enter fullscreen mode Exit fullscreen mode
// presentation/cubit/user_cubit.dart
class UserCubit extends Cubit<UserState> {
  UserCubit(this._repository) : super(const UserLoading());
  final UserRepository _repository;

  Future<void> load(int id) async {
    emit(const UserLoading());
    try {
      final user = await _repository.getUser(id);
      emit(UserSuccess(user));
    } catch (e) {
      emit(UserFailure(e.toString()));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Five files instead of eight. No mapping code, no forwarding class, and the boundaries between layers are still there. The behavior is identical, and you can open the Cubit and understand the whole flow in one read.

Going through the decisions

1. Entity, Model, and DTO

If they have the same fields, you're writing the same class two or three times and maintaining mappers between them. One class is enough.

The trade-off, stated honestly: in the lean version, User knows about JSON, so a change in the API format touches your core class. For many apps that's acceptable. It stops being acceptable when:

  • the API shape differs significantly from what your app needs (nested structures, different names, fields you don't want in the UI),
  • you consume multiple data sources for the same concept (remote API + local database),
  • the backend changes often and you want to isolate the damage.

In those cases, split them. The point is to split because you have a reason, not because the diagram has three boxes.

2. Use cases

A use case that only forwards a call is boilerplate. In most features the Cubit can call the repository directly.

A use case earns its place when it contains real logic:

  • it combines data from multiple repositories,
  • it applies business rules that don't belong in a Cubit or a repository,
  • the same operation is reused across several Cubits or screens.
// This one is worth having: it coordinates two repositories and applies a rule.
class CheckoutOrder {
  const CheckoutOrder(this._orders, this._payments);
  final OrderRepository _orders;
  final PaymentRepository _payments;

  Future<Receipt> call(Order order) async {
    if (order.items.isEmpty) throw EmptyOrderException();
    final payment = await _payments.charge(order.total);
    return _orders.confirm(order, payment);
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Repository interfaces

Many developers create a repository interface for one reason or (i was doing that at least): so they can mock it in tests. But in Dart, every class has an implicit interface, so you can mock a concrete class just fine:

class MockUserRepositoryImpl extends Mock implements UserRepositoryImpl {}
Enter fullscreen mode Exit fullscreen mode

So the testing argument alone doesn't justify the interface.

And yet, I still write repository interfaces in many of my projects, for a different reason: the interface acts as the contract of the feature. When I want to know what a feature does, I open that one file and read the list of operations, without wading through the implementation details (HTTP calls, caching, parsing).

That's the real lesson. I'm keeping something optional, but I know why I'm keeping it. If a feature is tiny and the contract adds nothing, I skip it. If a feature is large and the contract helps people navigate it, I keep it. Same pattern, different decision, and both are valid because the reasoning is explicit.

4. Cubit or Bloc

Bloc and Cubit come from the same library. A Cubit exposes functions and emits states. A Bloc adds events and event transformers on top of that.

In most features I only need the Cubit. If a screen later needs things like debouncing a search field or processing events in a specific order, upgrading that Cubit to a Bloc is straightforward. Starting with the simpler tool and upgrading when needed is cheaper than starting with the heavier one "just in case".

A quick decision guide

Layer / pattern Skip it when Keep it when
Separate Entity / Model / DTO Same fields, same shape API shape differs from your needs, multiple data sources
UseCase It only forwards a call Combines repositories, holds business rules, reused across screens
Repository interface Tiny feature, nothing to document You want a readable feature contract, multiple implementations
Bloc Cubit does the job You need events, transformers, event ordering

This isn't a rulebook. It's a starting point for asking the right question each time.

Pragmatic doesn't mean careless

I want to be clear about what I'm not saying:

  • I'm not saying skip testing. I test business logic seriously.
  • I'm not saying architecture doesn't matter. A messy codebase with no boundaries is expensive.
  • I'm not saying every project should be lean. A large app with many developers and complex domain rules may need most of those layers, and that's fine.

What I am saying is that you should understand the different ways to reach the same goal and the trade-off of each one, so you can pick the one that saves time for your project instead of the longest path because someone said it's "the best way". And that choice isn't permanent. A feature that starts lean can grow a use case or a separate model later, when a real reason appears. Adding a layer when you need it is cheap. Removing ten layers you never needed is not.

Conclusion

Patterns are tools that serve you, not rules you serve. Clean Architecture, repositories, use cases, and Bloc all solve real problems, but only for the projects that actually have those problems.

The examples in this article are only a starting point. The same question applies everywhere: your state management choice, your folder structure, your testing strategy, the packages you depend on. For each one, understand the options, understand what each one costs, and pick what fits your project, not what someone told you is the best way.

Next time you create a new class or layer, ask yourself what it does for this feature. If you have a good answer, keep it. If your only answer is "that's how it's done", it's worth a second look.

What's a layer or pattern you kept using just because everyone else does? I'd love to hear in the comments.

What's a layer or pattern you kept using just because everyone else does? I'd love to hear in the comments.

Top comments (0)