Перейти к основному содержимому

"Clean" Architecture vs pragmatic architecture

· 19 мин. чтения

This article is based on a meetup talk I gave in January 2023.

I'm working as a Staff Mobile Engineer at Mews. What staff engineers usually do differs from company to company and even department to department. In my case, it means that I do not belong to any specific team, but rather, I help other teams on an ad hoc basis with things like code reviewing, mentoring, discussing architecture, and solutioning.

Besides this, I do some external mentoring and consulting, and of course, one of the most popular topics we discuss is app architecture. Since Clean Architecture is one of the most popular for apps in general and mobile and Flutter apps in particular, we discuss a lot of questions like what it is, how to do it better, and how to go about app architecture from the Clean Architecture point of view.

Here, I will be discussing Clean Architecture as well. The word "clean" in the title isn't in quotation marks because I see Clean Architecture as the opposite of pragmatic or vice versa. It's just that I often see many implementations of Clean Architecture in different tutorials and real-life projects that, from my point of view, don't follow its core principles. At the same time, there are some issues with the architecture itself. I believe it's not always a perfect fit for Flutter apps, so let's start with these issues.

Problems

First of all, Clean Architecture was created with a different environment in mind. Robert Martin speaks about the whole software system in general, so concerning the web application, it will be both backend and frontend under these Clean Architecture rules. Many of the things discussed there are considered to be implementation details. UI, deployments – Robert Martin speaks of them as implementation details that good architecture should not ignore but rather postpone associated decisions until the very last moment. But when it comes to Flutter app architecture, a lot of these things are already fixed: we have a fixed platform – it's mobile, a fixed language – Dart, even the framework, Flutter, is fixed.

Of course, it affects our architectural decisions, and it allows us to make the architecture simpler: we don't need to introduce some abstractions because, for example, we are not going to swap Flutter with some other framework – it would be a completely different app. Also, Robert Martin pays a lot of attention to things like separating deployments, which again, in the case of the Flutter app, don't make sense: we cannot have microservices in the mobile app and deploy them separately. In general, Clean Architecture is very defensive: it introduces a lot of abstractions which, as Robert Martin says, are not always needed, but it can be challenging to implement Clean Architecture for Flutter apps fully and properly.

But, as I said, my main complaints are about the implementation. I often see tutorial authors proposing to structure the application by layers. What do I mean by that? Check what's probably the most popular image you can find when googling Clean Architecture:

The Clean Architecture

It's the division of layers where you have entities with business rules on top, then use cases, controllers, presenters, and so on. And many developers take this picture as their guide for the folders structure:

> app
> pages
> widgets
> utils
> data
> repositories
> helpers
> domain
> entities
> usecases

The problem with this approach is that it's not scalable. When your app grows larger, you see that these three or four layers are not enough. You need to modularize your app further, anyway.

It's also not properly encapsulated: if you want your presenter layer to use something from the use cases or models (from the domain layer), you need to make these use cases or models public. But that means that any other part of the app can use them as well and, for example, the authentication presentation module can import models or use cases from the favorites module domain layer. This is not what we usually want.

Also, this architecture doesn't scream enough. As Robert Martin says, architecture should scream about its intentions, about what it is used for.

I hope that you all agree that the structure on the right gives us a lot more information about what the app is doing:

> app                               > app
> pages > screens
> widgets > features
> utils > auth
> data > movie_details
> repositories > favorite_movies
> helpers > popular_movies
> domain > search
> entities
> usecases

On the left, we see the common structure, so if we take two apps from totally separate domains, they will look exactly the same: there will be pages, repositories, use cases, and so on. At the same time, on the right, it's clearly visible: okay, obviously the app has something to do with movies, we have some search functionality, and we can mark some movies as favorites. It's screaming that this is a movie app.

Another pretty big problem is that these authors take into consideration only one part of the slicing: I mentioned slicing by layers, but we also have slicing by features, and it's much more important. In a large app, you can have hundreds of different features as opposed to three or four different layers. How these features communicate with each other and how they depend on each other is much more significant. Not taking this into account can quickly turn your architecture into a mess.

Finally, the authors try to build the framework and say that this will be the universal framework to fit any app, but this is impossible. Clean architecture is not about the framework but about guides and rules on how to build the architecture. As Robert Martin says:

The component structure cannot be designed from the top down. It is not one of the first things about the system that is designed, but rather evolves as the system grows and changes.

For me, this is the quintessence of this framework-minded approach:

lib/
app/ <--- application layer
pages/ <-- pages or screens
login/ <-- some page in the app
login_controller.dart <-- login controller extends Controller
login_presenter.dart <-- login presenter extends Presenter
login_view.dart <-- login view, 2 classes extend View and ViewState resp.
widgets/ <-- custom widgets
utils/ <-- utility functions/classes/constants
navigator.dart <-- optional application navigator
data/ <--- data layer
repositories/ <-- repositories (retrieve data, heavy processing etc..)
data_auth_repo.dart <-- example repo: handles all authentication
helpers/ <-- any helpers e.g. http helper
constants.dart <-- constants such as API keys, routes, urls, etc..
device/ <--- device layer
repositories/ <--- repositories that communicate with the platform e.g. GPS
utils/ <--- any utility classes/functions
domain/ <--- domain layer (business and enterprise) PURE DART
entities/ <--- enterprise entities (core classes of the app)
user.dart <-- example entity
manager.dart <-- example entity
usecases/ <--- business processes e.g. Login, Logout, GetUser, etc..
login_usecase.dart <-- example usecase extends UseCase or CompletableUseCase
repositories/ <--- abstract classes that define functionality for data and device layers
main.dart <--- entry point

I found this package on pub.dev. The author tries to build the framework out of Clean Architecture and introduces a lot of abstractions like Сontroller, Presenter, View, ViewState – and this is all just for the UI layer, there are tons of other abstract classes and interfaces as well. For me, this architecture looks messy: to find something meaningful there, to find that there are some classes like UserManager or LoginUseCase, you have to go deep down into the folder structure. Even from the point of maintainability, if you need to change something in the login flow, you'll most likely go through multiple folders across the whole repository.

Pragmatic approach

If we talk about a pragmatic approach to architecture in general, and to Clean Architecture in particular, we need to start with some core principles that lay the foundation of this Clean Architecture.

For me, the first principle is the principle of acyclic dependencies. Your dependencies should form a directed acyclic graph. If we take a look at the definition of a directed acyclic graph on Wikipedia, it says that a "directed acyclic graph is a directed graph with no directed cycles". Very correct yet totally useless definition, right?

For our use case, it just means that we have a representation of our modules and the dependencies between them:

Directed Acyclic Graph

If we have an arrow from module A to module B, it means module A depends on module B. And being acyclic means that we can take any module, let's say module B, and follow the direction of each arrow (e.g. from C to D and then to E, or from C to E directly), and there would be no way to go back to module B again.

Why is it important? First of all, having cycles turns modules within a cycle into one big module: if you have module A depending on module B and module B depending back on module A, it means that you have one big module – you cannot use these modules separately, you cannot test them separately, so it will be just one big module AB.

Another issue is that for architecture to be understandable and maintainable you want to distinguish between stable and non-stable components. For example, component E here is very stable – it doesn't depend on anything else, so for it, the only reason to change is if the requirements for the module itself are changing. At the same time, module A is very non-stable because it depends on every other component in the system. It means that if any other component changes, there is a high chance that module A will require some changes as well. At least, it's good to understand what components in your system are stable and non-stable, and at the same time, if you have a module that you know will be non-stable, that you know will change a lot (e.g. its requirements are not finalized yet), it's better to separate it from stable modules. Also, stable components should not depend on non-stable components, otherwise, they would become non-stable as well.

Another principle is structuring by components. As Robert Martin says:

For most applications, maintainability is more important than reusability. If the code in an application must change, you would rather that all of the changes occur in one component, rather than being distributed across many components.

By component here, I mean what Simon Brown defines in the same book, The Clean Architecture:

A grouping of related functionality behind a nice clean interface, which resides inside an execution environment like an application.

The third principle is the principle of reasonable abstractions: every abstraction adds complexity to the app, so don't add abstractions that do nothing useful. Robert Martin says the same about boundaries:

Your goal is to implement the boundaries right at the inflection point where the cost of implementing becomes less than the cost of ignoring.

Again, one of the examples that I found:

class GetConcreteNumberTrivia implements UseCase<NumberTrivia, Params> {
final NumberTriviaRepository repository;

GetConcreteNumberTrivia(this.repository);


Future<Either<Failure, NumberTrivia>> call(Params params) async {
return await repository.getConcreteNumberTrivia(params.number);
}
}

class Params extends Equatable {
final int number;

Params({ this.number});


List<Object> get props => [number];
}

It's a pretty popular tutorial on how to build Clean Architecture and clean code in general. For me, it's bad on many levels simultaneously. First of all, it just screams "Java 6" with all these abstract classes, generic parameters, and interfaces. Even on the code level, it can be simplified, but the main problem here is that this class pretends that there is logic behind it. But there is no logic – all this class does is just proxying the call to the repository method, which even has the same name getConcreteNumberTrivia, and that means that we just add complexity here. We make the whole solution more complex, but we do not hide anything. For the client, it would be simpler to just call the repository method directly.

What are the outcomes? First, Clean Architecture doesn't enforce a structure. It's not a framework, it just provides the rules and guides on how you want to abstract something and how you want to form your dependencies so that they don't form cycles. Start with a really simple structure. Your goal is not to create correct architecture from the very beginning, your goal is to make it easy to change when the requirements change. Remember that architecture evolves with the project because architecture is the project. It's about how your components work together and depend on each other. As simple yet powerful advice, keep an eye on your dependencies diagram because it can serve as a litmus test that can quickly show you if you're going in the wrong direction if your architecture is becoming more complex and messy.

But of course, the main advice is: "Think!" There is no silver bullet or universal framework that can fit any app.

Example

As a quick example, we can take a look at my sample project. It is a very simple client for TheMovieDatabase API: it provides functionality such as authenticating, adding movies to favorites, searching for movies, and so on. We can see that even for such a small application, the dependency graph can look messy and big:

Dependencies

But no worries! We don't need to work with this graph daily.

For now, we just need a couple of things from this graph. First, we can make sure that it's acyclic, and second, it can give us the overall picture of different modules and the dependencies between them. Since the whole graph is acyclic, we can take one module and look at it separately, and the dependencies in the module will also be acyclic. Inside this module, we can see the layered structure of the presentation, use cases, models, and so on.

Overall structure

If we take a look at the overall structure, we can see that there is a top-level app folder with some glue code, routes, and entry points, and a core folder with some shared functionality that is not connected to any specific feature. The main part of the app is the features folder, where we can find components that are more or less independent from each other, and that provide some related functionality behind an interface. This is exactly the definition of the component by Simon Brown.

Favorites module

Favorites module structure

If you take a deeper look into some of the modules, let's say the favorites module, we can see that there is an src folder. It's kind of a hack because currently, Dart doesn't provide a way to define a properly established module with a separated internal structure and public interfaces. We can just pretend that everything in the src folder is internal and should not be used from outside the module. We can even set up a linter so that it will give you an error if you try to import something from the src folder from outside of this module folder.

Inside the src folder, we have an API client and repository, which both provide data from the backend. We have a BLoC that encapsulates some UI-agnostic application logic: this is not a ViewModel from the MVVM pattern, but rather an implementation of a use case from Clean Architecture. Next, we have widgets that are the only public entity in this module: any client who wants to use this module can only do it through this widget. The Widget is not the View from MVVM, it can hold some UI-related logic inside.

Speaking of the difference between the API client and repository: the API client should be as close as possible to the real API structure on the backend. It doesn't have any app logic, it just provides the methods that are closely mapped to the backend API structure, and it converts requests and responses from JSON to Dart classes and back:

()
abstract class FavoritesApiClient {
factory FavoritesApiClient(
Dio dio, {
required String baseUrl,
}) = _FavoritesApiClient;

('/account/{account_id}/favorite')
Future<void> markAsFavorite({
('account_id') required int accountId,
('api_key') required String apiKey,
('session_id') required String sessionId,
() required MarkAsFavoriteRequestDto body,
});

('/movie/{movie_id}/account_states')
Future<MovieAccountStatesResponseDto> getMovieAccountStates({
('movie_id') required int movieId,
('api_key') required String apiKey,
('session_id') required String sessionId,
});

('/tv/{tv_id}/account_states')
Future<TvAccountStatesResponseDto> getTvAccountStates({
('tv_id') required int tvId,
('api_key') required String apiKey,
('session_id') required String sessionId,
});
}

The repository is the entity that provides methods convenient for the app itself and that are meaningful from the app logic point of view. It can, for example, hide some non-related complexity, like the fact that we need to provide an API key with every request. Or it can compose several different method calls to the API, process them somehow, and turn them into models that, yet again, make sense to the app logic (mainly to the BLoC, as it's the primary consumer of the repository):

class FavoritesRepository {
FavoritesRepository({
required FavoritesApiClient api,
required String apiKey,
}) : _apiKey = apiKey,
_api = api;

final FavoritesApiClient _api;
final String _apiKey;

AsyncResult<void> addToFavorites({
required ContentId id,
required Authenticated info,
}) =>
_api
.markAsFavorite(
accountId: info.accountId,
apiKey: _apiKey,
sessionId: info.sessionId,
body: MarkAsFavoriteRequestDto(
mediaType: id.type.toDto(),
mediaId: id.value,
favorite: true,
),
)
.toEither();

// ...
}

BLoC, in turn, encapsulates the app logic. It can use repository methods and add some functionality on top of them. It emits a set of app states, e.g. the app is busy doing something, or we have an error while processing the user input, or we succeeded in making a request:

Future<void> _handleAddToFavorites(
AddToFavorites event,
_Emitter emit,
) async {
emit(const FavoritesState.processing());

final newState = await _repository
.addToFavorites(id: _contentId, info: event.info)
.foldAsync(
(_) => const FavoritesState.failure(),
(_) => const FavoritesState.fetched(isFavorite: true),
);
emit(newState);
}

The widget usually uses the BLoC, and it can act as a simple proxy that takes the user input and sends events to the block, e.g. a user presses the button and the widget decides to send an event for adding the movie to favorites, or for removing it from favorites. The widget also renders the interface – it can decide whether the button should be enabled or disabled and whether we should display a progress bar:

BlocBuilder<FavoritesBloc, FavoritesState>(
builder: (context, state) => state.maybeMap(
fetched: (state) => FloatingActionButton(
onPressed: () => context.read<FavoritesBloc>().add(
state.isFavorite
? FavoritesEvent.removeFromFavorites(info: info)
: FavoritesEvent.addToFavorites(info: info),
),
child: state.isFavorite ? favoriteIcon : nonFavoriteIcon,
),
orElse: () => const FloatingActionButton(
onPressed: null,
child: nonFavoriteIcon,
),
),
);

But it can also act as the manager of some UI logic. For example, if we know that to add a movie to favorites, we need to provide some authentication info, then the widget can try to get this info. If it's not available (meaning the user is not authenticated), then it shows the authentication dialog (showing a dialog is pure UI logic). If the authentication was successful and we got the credentials, the widget can send the corresponding event to the BLoC with all the required data:

if (info is! Authenticated) {
return FloatingActionButton(
onPressed: () async {
final info = await AuthDialog.show(context);
if (info == null || !mounted) return;

context
.read<FavoritesBloc>()
.add(FavoritesEvent.addToFavorites(info: info));
},
child: nonFavoriteIcon,
);
}

Popular movies module structure

If we take a look at another module, we can see that the structure is flexible and there are no useless abstractions. We have no BLoC here because we don't have any app logic – the widget just calls the repository directly.

For example, if all you need is just fetching some data, displaying the progress bar while the data are being fetched, and then either showing an error message or displaying the successful result, it's just work for FutureBuilder. We just need to provide the future there (it can be initiated by getting the repository from the service locator and calling the corresponding method right in the initState method). That's it – there is no more logic there:

class _PopularMoviesWidgetState extends State<PopularMoviesWidget> {
late final AsyncResult<IList<PopularMovie>> _result;


void initState() {
super.initState();

_result = sl<PopularMoviesRepository>().fetchPopularMovies();
}


Widget build(BuildContext context) => SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Popular Movies',
style: Theme.of(context).textTheme.headline4,
),
),
SizedBox(
height: 304,
child: FutureBuilder(
future: _result,
builder: (context, snapshot) {
final data = snapshot.data;
if (data == null) {
return const Center(child: CircularProgressIndicator());
}

return data.fold(
(_) => const Center(child: Text('Something went wrong.')),
(movies) => PopularMoviesList(
movies: movies,
onItemPressed: widget.onItemPressed,
),
);
},
),
),
],
),
);
}

Outcomes

Coming back to the outcomes:

  • Architecture doesn't enforce a structure, but I usually recommend organizing folders and grouping them by components. These components do not necessarily reflect the app hierarchy. For example, the FavoriteButton widget can be located in the movie details, it can be on the search results screen, but still, it will sit in the top-level feature folder because this is a separate module that provides some piece of functionality and is responsible for it. It can be used anywhere in the app.
  • Start with a simple structure. It's easier to add abstractions than to remove them.
  • Remember that the architecture evolves with the project, so don't try to foresee the future. Remember the YAGNI principle.
  • Keep an eye on your dependencies diagram, probably by using some automated tools. For example, in this repository, I use lakos library, which can build the diagram and even warn you if you have cycles in the dependencies.
  • And again, think! It won't be clean architecture versus pragmatic architecture. You can have clean and pragmatic architecture.

Of course, this is a very big topic, and a lot can be discussed here, so if you're interested, feel free to check out the repository, create issues, raise questions, and let's have a discussion.