Flutter MVVM and Clean Architecture - Part 3: Multi-packages structure

We've covered the basics of MVVM in the previous posts, but now any example moving forward would be strongly hypothetical or not really how I would do it myself in my apps. It's best we bite the bullet and have this post to go over an approach that has been working well for me in medium to large-ish applications. I say "large-ish" because I've worked in "normal companies"' large applications, but never for huge apps like a FAANG's with several millions of lines of code and hundreds of mobile developers.

Intro

Lots of writing in this post and not so much coding, but I feel like these things often get overlooked and it's very hard for developers who haven't worked on medium to large-ish size apps and teams to have an idea on how to set it up. I'll link you to some key files on github to make our lives easier.

We're going to go through concepts of Clean Architecture, SOLID and others, but bear in mind these are my takes and approaches to them, which doesn't mean it's exactly like the authors have envisioned it. I hope you do the same and take from this series what's relevant to you. Remember, there's no silver bullet nor the "perfect approach".

Some parts of this post might not seem to make a lot of sense at first, especially because there's only so much we can go over this post alone, which is the reason why this is a series of posts! Today we're setting up the ground work for the project's organisation and structure, and we'll build a simple app that performs network requests and some other common things that people usually do on day to day work.

I suggest you to read more about Clean Architecture, SOLID and design patterns in general, the more you learn and put in practice the more it makes sense. There are many books on the topics too!

Monorepo, multi-packages and multi-apps

There are many pros and cons for Monorepos. I personally think it's very easy to structure reusable packages and all apps of a same company under the same repository. Usually apps from a same company tend to share many common things like Design Systems, authentication mechanisms and even APIs in general.

We won't go over setting up packages as a line by line tutorial, you can learn more about it in the docs, we have heaps more to cover architecture-wise

Based on the structure proposed in the Clean Architecture, we'll split our code into several packages, and potentially a few apps.

generic clean_architecture_apps.jpg

Okay, it's a lot to take in. I suggest you take your time to digest it, read this post entirely, then come back to this image and try to make reason of it.

Common Package

This is the most generic package where some highly reusable utils, common to all apps, can be placed. This package must not depend on any other internal package.

Have a look at its pubspec dependencies setup.

Entities Package

Contains all entities/models. Most commonly mapping JSON endpoints. Entities must not have any business logic. They’re plain data structures. This package must not depend on any other internal package, nor any other UI related packages, including material, etc. Keep it as UI agnostic as possible.

Have a look at its pubspec dependencies setup.

Domain Package

All the general business logic must live in this package in form of composable Use Cases (also known as Interactors). These Use Cases must be contained to single responsibility logic. One might have a Use Case that's as simple as making a call to a "data source" through a repository interface and not perform any other operation on top of that, but there will be many scenarios where it'll be required to compose multiple use cases into a new one in order to satisfy a piece of business logic. For example, a business requirement may be that upon logging out all user’s data must be wipe out and user should be prompted to give feedback. So creating a LogoutUseCase that makes use of ClearUserDataUseCase and PromptFeedbackUseCase makes sense.

The Domain Package depends solely on Common and Entities packages (and any common third party package that all packages/layers use, for example, rxdart, but no UI related packages, including material, etc, should be imported. Keep it as UI agnostic as possible). It also exposes Repository Interfaces which abstract any data source from the business logic and might be implemented by multiple different packages as needed.

Finally, the Domain Package might have some Data Structures which, as the name suggests, are structures representing data for certain business logic. That might be by aggregating data from multiple entities or other things.

Have a look at its pubspec dependencies setup.

Data Package

Contains the concrete implementations of the repositories responsible for orchestrating the services calls and potentially keeping state of pieces of information as required.

This package depends on Entities and Common Packages directly, as well as on the Domain Package in order to implement its repositories interfaces and access Data Structures. No direct access to Use Cases interfaces or implementations is allowed. Also, no UI related packages, including material, etc, should be imported. Keep it as UI agnostic as possible.

Services Interfaces are exposed by this package and might be used by other packages/apps to implement them in form of Network Clients, Databases/Storage, Platform Specific Libraries (Monitoring, Remote Toggles, Push Notification, In App Purchase, etc), adaptors and others.

Have a look at its pubspec dependencies setup.

Clients Package

Implements some of Data Package's Services Interfaces in form of Network Clients common to all apps. Since these network calls are platform agnostic it makes sense to have a specific reusable package for this, however you may choose to leave them in the App Common Package or App directly.

This package depends directly on Common and Entities packages, as well as on the Domain and Data packages but only to access their interfaces. No direct access to any form of Use Cases or Repositories concrete implementations is allowed, nor any UI related packages, including material, etc, should be imported. Keep it as UI agnostic as possible.

Have a look at its pubspec dependencies setup.

Design System Package

Contain Design System themes and UI components common to all apps. This package allows UI specific dependencies, like material and other third party. You might choose to simplify and not have this package and have it all in App Common Package or App directly.

Have a look at its pubspec dependencies setup.

App Common Package

UI helpers, adaptors, utils and any other code that belongs to the App layer but is generic enough to work with any app should be put in here, so apps will be able to import them by default.

The App Common Package will not have any localisation specific code, and preferably no custom icons/images either, since these are trickier to share, requiring extra setup by its dependents. This package allows UI specific dependencies, like material and other third party.

You might choose not to create this package if you know for a fact you will only have one app. You can always add this later on in case you were to create more apps, and move reusable code in here.

We won't create this package for the app sample that we'll be building in the future posts.

Apps

Apps must import all packages they depend on and glue all implementations to their respective interfaces in the Dependency Graph file using the Common Package's Service Locator tool (more of that in future posts).

For example, registering a link between a Clients Package's concrete implementation class to a Data Package's interface, and so on.

No usage of concrete implementations for Use Cases, Repositories and Services are allowed outside the Dependency Graph file, only their interfaces must be used.

Platform specific Services, such as, Databases and other platform specific capabilities (Push, camera, etc) must be implemented in form of Adaptors in the app layer (or in the App Common package if applicable) whilst making the adaptor conform to the Data Package's Services Interfaces where applicable.

Widgets should contain the least amount of logic as possible. It should forward users inputs to its ViewModel and listen to its changes to update itself. ViewModels should only contain presentation logic, and make use of UseCases to execute business logic, exposing Streams and Callbacks to update the Widget's UI. ViewModels should not make direct use of Repositories and Services, UseCases are always preferred.

Have a look at its pubspec dependencies setup.

Some takes on the proposed structure

A few consideration from my experience using this exact same setup, as well as some variations to it, both as a native iOS Developer (which I also wrote a short series about) and as a flutter developer.

Monorepo and dependencies

Being a monorepo allows us to easily setup the dependencies through paths in their pubspec. All code lives in the same repository, all mobile developers contribute to it, everyone sees the pull requests, any code changes will trigger tests for the affected areas. Quick iterations. However, there's nothing preventing you from having the packages come from different repos, but it's a much bigger overhead.

Also, adding dependencies to a package, whether it's a third party or not, will imply in a transitive dependency to any package/app that depends on it. Choose wisely!

Why packages?

In my personal experience, it's easier to onboard people, including junior developers, who don't have experience with Clean Architecture in a project well structured and broken down in packages. Packages enforce strong boundaries. You can't import something from a package that hasn't been setup to be a dependency of the package/app you're using.

Strong boundaries and its limitations make you really think about dependencies, reusability and the impact of that code in other packages. It makes you a better developer, in my opinion.

When scaling a project and a team, conventions and structures become harder to follow. People ship code so often that it's easy to put files in wrong places, access certain implementation directly, and so on.

It's easier to "undo packages", since their dependencies are contained and the boundaries as well set, then to move "loose code" that often don't respect boundaries into a package. So you change your mind about packages, you might just remove them later.

How to share the packages in multiple apps?

Often when you have multiple apps, you won't have exactly the same API nor the same logic in all apps.

You can create a folder for each app in each package, as well as a shared folder for things that are used by all. In each of the folder you have an umbrella file that exports all files in that folder. Then each app will only import the umbrella related to that app. For example, an app_a will only import domain through:

import 'package:domain/app_a/domain.dart';

Dart's tree shaking mechanism will ensure that only the imported files of the package will be actually compiled, ignoring the unused folders.

An alternative is to create multiple packages, more specific per app, which increases the complexity of the repo.

Design System and Design System Demo app

Nowadays it has become more common companies have their own Design System with common ui components, definition of typography, spacing, colour palette, etc. This structure makes it very easy for us to add a Design System Demo app where you can implement examples of the design system and publish it on Firebase Web or as a mobile app for your designers to have a look, or developers have it as a reference.

Are Use Cases really necessary?

A common discussion when it comes to Clean Architecture, is whether Use Cases/Interactors are necessary. It's really up to you.

I've seen projects where the developers made the decision to allow repositories to be accessed directly in the view models, so they didn't have to implement one use case for each action of the repository (FetchUsersUseCase, UsersStreamUseCase, UpdateUserUseCase, etc) and their respective test files. Instead, they'd only implement use cases when they knew they have some specific business logic to be implemented.

Note, however, that while this approach seems easier to use, it gets trickier as the project grows and the team scales. It requires all developers to be in sync with when a use case should be created or not, as well as, being prepared for confusing situations where developers accessed a specific method directly from the repository in the view model without realising that they should've used an existing use case that had extra business logic that needed to be considered, for example.

Entities and DTOs

I've taken a simpler approach to Entities which I basically allow for JSON parsing as well as simple data structure common to the apps. In the Clean Architecture book you'll see a different approach where they use DTOs for these, and lots of mapping back and forth are required.

There are pros and cons for each approach. Again, it's up to you. I prefer it a bit simpler in the Entities and when I need a temporary object that usually aggregates multiple entities or has some custom data for a specific ViewModel or something, then I create a Data Structure in the Domain Package.

More packages as needed

This is a suggested initial setup, but feel free to break things down into more packages as needed. You might have a package for adaptors, or for data structures, or even packages to move concrete implementation of domain and data to their own to ensure they wont be mistakenly used where they shouldn't (might be good for bigger and less experienced teams, for example), or for specific features, whatever makes more sense to you.

Interfaces FTW

The packages' strong boundaries are only possible thanks to interfaces. You can look at it from bottom top.

The view model needs to performs actions FetchUsersUseCase, UsersStreamUseCase, UpdateUserUseCase, etc. So all it needs are interfaces that allows to do it. How they are implemented is irrelevant to the view model.

The domain then exposes these Interfaces, and also contains their implementations. But for the implementations be able to do the work, they need some data. So domain specifies some repository interfaces to manipulate the data. Again, their implementation is irrelevant to domain, all it needs is a "contract" that says that UserRepository will allow the use cases to fetch, update and listen to data changes.

The data layer will then implement these domain's data interfaces. However, in many cases the repository will need certain services to perform the tasks. It then exposes the service interfaces. How they are implemented, if through REST, graphql, sqlite, shared preferences, adaptors, etc, is irrelevant. Repositories just need to manipulate the data, no matter where from.

The clients packages then implement the services interfaces that handle network requests, since often there will be many implementations for the apps, as in, many api clients, each manipulating data for one specific API resource (UserClient, ProfileClient, ProductClient, etc). Database/persistence based implementations, as well as, other device specific services such as Push, Deeplinks, etc, are often one-off implementation, and for the sake of simplicity, they are implemented as Adaptors in the App or App Common package. However, feel free to create an Adaptors package, just like we did for Clients.

There you have it. It all comes in together as a chain. This approach is easy to extend and modify since it's abstract enough, but does incur some extra work to maintain the setup, which is often a good trade off for medium to large-ish apps.

Orchestrating all packages and apps

Having to access the directory of every single package/app in order to run flutter commands can be quite tedious, especially for simple tasks such as fetching dependencies or runnings tests.

My bright friend @QuirijnGB introduced me to melos:

Melos is a tool that optimizes the workflow around managing multi-package repositories with git and Pub.

He also wrote the base of the melos scripting I use, which is less than a couple hundred lines long, where we can run things like melos run get in the root folder of our monorepo to fetch all dependencies, melos run coverage to run all tests and gather coverage for the project, and a couple of other useful scripts.

There's also an alternative script melos run diffcoverage which only runs tests and gather coverage for files which have changed. So you can setup your CI to run diff tests on pull requests, and full tests when merged into main, for example.

Code coverage

Running dart test --coverage generates a coverage/lcov.info file, however it only takes into account files that where imported into the tests 🤦.

In order to ensure the project is fully covered*, we've included a dart-coverage-helper script that automatically imports all files into a temporary file in the tests. The coverage.sh script run by melos will then remove all files defined in coverage_exclusions and format them in a nice way that's understood by most CI as well as a html version under coverage/html/index.html that allows you to visualise the coverage in the browser.

Now, I put a star * in the "fully covered" because I usually ignore coverage for automatically generated files, entities (they get tested as a side effect when the clients parse them for the response though), design system, as well as widgets. Widgets are ignored because of our ground rule that widgets must not contain logic, only "bindings" from and to the ViewModel, so no need for unit tests. You can write integration and snapshot/golden tests for those.

You can see all these files in the part-3 branch on github repository.

Conclusion

Now we're setup for building the app out and make use of all these packages in practice. In the next post we'll integrate the api clients for our new app 🤩.

All of this might not make much sense right now, but as we build out the apps in the future posts things will start to settle in.

Don't forget to check out the full source code for this project on Github.

Thanks for reading and let me know what you think. Feel free to message me up on Twitter too!