Flutter App Architecture - Patterns, Principles and Anti-Patterns
Every project needs a well-defined, clear and strict architecture that is easy to maintain. Building it is hard and requires experience.
The article series I am beginning with this piece will be a valuable resource for anyone looking to improve their skills or discover something new and interesting.
Goals of architecture
The goals of architecture are twofold: First, it should be simple and efficient, ensuring that developers do not spend excessive time on basic tasks. Second, it must be maintainable, so that new features can be added easily.
These two criteria are equally important. It is important not to sacrifice quality for speed, or vice versa. The architecture should strike a harmonious balance between these aspects to facilitate the efficient development of high-quality applications.
Table Of Contents
This part (part one) focuses on patterns, principles, and anti-patterns.
Principles and Patterns
Fortunately, architecture is a well-established field with abundant resources available on principles and patterns that are validated by many generations of developers.
Moreover, it is a field that does not differ significantly between projects or areas. For instance, both backend and frontend developers follow the same principles to establish good architecture.
I have prepared here some principles and patterns that will be our basis for architecture.
Separation of Concerns
The Separation of Concerns (SoC) is a fundamental design principle that organizes code into distinct sections, each responsible for a specific aspect of the software's functionality. This results in modular programs, simplifying both their development and ongoing maintenance.
In practice, following the SoC principle often means dividing the architecture into multiple layers, each with a defined role. Common layers include:
- Presentation (widgets): Responsible for the user interface and interaction with user.
- Business Logic: Contains core functionality and rules of the application.
- Data: Manages data storage and retrieval, often interfacing with databases or other storage mechanisms.
SOLID
Solid is an acronym for five essential principles in software development. Its goal overlaps significantly with ours: to write maintainable and scalable code.
Single Responsibility Principle
A module should have only one reason to change - its responsibility. Following this rule helps to keep our classes focused and fixes common design flaws like a God Object.
For example, authentication module should not have logic for sending emails, because module will have two reasons to change (email and authentication). Instead, it is much better to move the email logic into another module.
BAD:
// Violates SRP because it has more than one reason to change.
class AuthenticationDataSource {
Future<User> logIn(String username, String password) async {
// ...
}
Future<User> signUp(String username, String password) async {
// ...
}
Future<void> logOut() async {
// ...
}
Future<void> sendEmailVerification() async {
// ...
}
}GOOD:
// Now this class has only one reason to change.
class AuthenticationDataSource {
Future<User> logIn(String username, String password) async {
// ...
}
Future<User> signUp(String username, String password) async {
// ...
}
Future<void> logOut() async {
// ...
}
}
// New class is introduced to handle email verification.
class EmailVerificationDataSource {
Future<void> sendEmailVerification() async {
// ...
}
}Open-Closed Principle
A module should be open for extension, but closed for modification. This means that instead of adding more functionality to a module, you should design your system so that you can do so without changing the module.
For example, instead of calculating the area of all shapes in a module, delegate the calculation of a shape to the interface, so that Rectangle or Circle has a method calcArea.
BAD:
// Violates OCP because it is not closed for modification
// When a new shape is introduced, this class needs to be modified.
class ShapeCalculator {
// Method that calculates the total area of all shapes.
double totalArea(List<Shape> shapes) {
return shapes.fold(0, (sum, shape) {
if (shape is Rectangle) {
return sum + shape.width * shape.height;
} else if (shape is Circle) {
return sum + shape.radius * shape.radius * pi;
}
});
}
}
GOOD:
// Now this class is closed for modification
// When a new shape is introduced, this class does not need to be modified.
class ShapeCalculator {
// Method that calculates the total area of all shapes.
double totalArea(List<Shape> shapes) {
return shapes.fold(0, (sum, shape) => sum + shape.area());
}
}
abstract class Shape {
double area();
}
class Rectangle implements Shape {
double width;
double height;
@override
double area() {
return width * height;
}
}
class Circle implements Shape {
double radius;
@override
double area() {
return radius * radius * pi;
}
}Liskov Substitution Principle
Classes should be replaceable by subclasses without affecting the program. Subtypes should not impose new boundaries on pre and post conditions. This helps to implement correct and logical inheritance.
For example, Robot should not subclass Human because it doesn't eat food (and will throw an error if such method is called).
BAD:
class Human {
void eat() {
// ...
}
void doSomething() {
// ...
}
}
class Robot extends Human {
void eat() {
throw Exception('I cannot eat!');
}
void doSomething() {
// ...
}
}GOOD:
abstract class Eater {
void eat();
}
abstract class Chargable {
void charge();
}
abstract class Doer {
void doSomething();
}
class Human implements Eater, Doer {
void eat() {
// ...
}
void doSomething() {
// ...
}
}
class Robot implements Chargable, Doer {
void charge() {
// ...
}
void doSomething() {
// ...
}
}Interface Segregation Principle
Clients should only depend on methods they use. This means that interfaces should be kept as small and direct as possible.
For example, there should be two interfaces Printer and Scanner, but no big one SuperPrinter (that combines the logic of both). However, if a device implements both of these functionalities, it will just implement needed interfaces.
BAD:
// Violates ISP because interface is too big.
abstract class SuperPrinter {
void print();
void scan();
void fax();
void copy();
}GOOD:
// Now interfaces are small enough.
abstract class Printer {
void print();
}
abstract class Scanner {
void scan();
}
abstract class Faxer {
void fax();
}
abstract class Copier {
void copy();
}Dependency Inversion Principle
Modules should depend on abstractions rather than concrete implementations. This ensures that components are reusable and interchangeable, so that communication goes through a defined contract.
For example, if your repository depends on a data source that has a clear contract, it won't be a problem to change the data source from local to network in future.
BAD:
// Violates DIP because it depends on concrete implementations.
class BooksDataSource {
Future<List<BookDto>> getBooks() async {
// HTTP request to get books.
}
}
class BooksRepository {
BooksRepository(this.localDataSource);
final BooksDataSource localDataSource;
Future<List<Book>> getBooks() async {
final books = await localDataSource.getBooks();
return books.map((book) => Book.fromDto(book)).toList();
}
}
GOOD:
// Now this class depends on abstractions.
abstract class BooksDataSource {
Future<List<BookDto>> getBooks();
}
class HttpBooksDataSource implements BooksDataSource {
@override
Future<List<BookDto>> getBooks() async {
// HTTP request to get books.
}
}
class LocalBooksDataSource implements BooksDataSource {
@override
Future<List<BookDto>> getBooks() async {
// Get books from local database.
}
}
class GraphQLBooksDataSource implements BooksDataSource {
@override
Future<List<BookDto>> getBooks() async {
// GraphQL request to get books.
}
}
class BooksRepository {
BooksRepository(this.localDataSource);
final BooksDataSource localDataSource;
Future<List<Book>> getBooks() async {
final books = await localDataSource.getBooks();
return books.map((book) => Book.fromDto(book)).toList();
}
}I have an article about SOLID, so you can check it out as well for a deeper understanding:
Coupling & Cohesion
Coupling and cohesion are important concepts in software design, each having a significant impact on the structure and maintainability of the system.
Coupling measures the interdependence between different modules in a software system. Low coupling is ideal because it indicates less interdependence between modules. With low coupling, changes in one module have limited impact on others, simplifying maintenance and updates.
Cohesion refers to the degree to which tasks within a single module are related and focused. High cohesion is desirable because it means that a module is dedicated to a single task or closely related tasks. This focus improves the readability, maintainability, and scalability of the system because each component has a clear, unambiguous purpose.
BAD:
class User {
String name;
String email;
User(this.name, this.email);
void login() {
// Login logic
print('User logged in');
}
void sendEmail(String content) {
// Email sending logic
print('Email sent: $content');
}
}GOOD:
class User {
String name;
String email;
User(this.name, this.email);
}
class AuthenticationService {
void login(User user) {
// Login logic using user details
print('${user.name} logged in');
}
}
class EmailService {
void sendEmail(String email, String content) {
// Email sending logic
print('Email sent to $email: $content');
}
}
Dependency Injection
Dependency Injection (DI) is a robust design pattern that greatly improves the handling and management of dependencies within an application. The core principle of DI is to avoid hard-coding dependencies within components or classes. Instead, dependencies are exposed externally, often through constructors or setters. This technique allows specific dependencies to be injected into different parts of the application as needed.
The use of dependency injection promotes a modular and cohesive architecture in software development. By separating the dependencies from the core business logic, DI provides a clear separation of concerns. This separation is beneficial in several ways. First, it makes the software more adaptable and flexible, since changes in dependencies or their implementation do not require major changes in the core business logic.
In addition, DI simplifies both maintenance and testing processes. Because components are decoupled from their dependencies, they can be developed, tested, and maintained independently. This independence is particularly beneficial for unit testing, where individual components can be tested in isolation, often with mock dependencies created specifically for test scenarios. As a result, software that uses dependency injection tends to be more robust, easier to debug, and more amenable to changes and enhancements.
BAD:
// This violates DI because classes create their own dependencies.
// This makes it hard to test and reuse the classes, also they are tightly coupled.
class DataSource {
// ...
}
class Repository {
Repository() {
dataSource = DataSource();
// or dataSource = ServiceLocator.get<DataSource>();
}
DataSource dataSource;
// ...
}
class Bloc {
Bloc() {
repository = Repository();
// or repository = ServiceLocator.get<Repository>();
}
Repository repository;
// ...
}
void main() {
final bloc = Bloc();
// ...
}GOOD:
// Now classes use constructor injection to get their dependencies.
// This makes it easy to test and reuse the classes, also they are loosely coupled.
class DataSource {
// ...
}
class Repository {
Repository(this.dataSource);
final DataSource dataSource;
// ...
}
class Bloc {
Bloc(this.repository);
final Repository repository;
// ...
}
void main() {
final dataSource = DataSource();
final repository = Repository(dataSource);
final bloc = Bloc(repository);
// ...
}We'll return to these principles in more detail in the next articles.
Anti-Patterns
To better understand how to build a good architecture, it's also helpful to know about patterns to avoid. They may work in the early stages, but our goal is to build an application that will live and grow, right?
Service Locator
Service Locator is a design pattern used as a replacement for dependency injection. The core idea is that there is a class that has two main methods - Register and Resolve. Register registers the dependency in a service locator when resolve retrieves it, initializes it, etc.
Here's a simplified example of a service locator and its usage:
class ServiceLocator {
static final _services = <Type, dynamic>{};
static void register<T>(T service) {
_services[T] = service;
}
static T get<T>() {
return _services[T];
}
}
class DataSource {
// ...
}
class Repository {
Repository() {
dataSource = ServiceLocator.get<DataSource>();
}
DataSource dataSource;
// ...
}
class Bloc {
Bloc() {
repository = ServiceLocator.get<Repository>();
}
Repository repository;
// ...
}
void main() {
ServiceLocator.register(DataSource());
ServiceLocator.register(Repository());
ServiceLocator.register(Bloc());
final bloc = ServiceLocator.get<Bloc>();
// ...
}The code above registers the data source, repository, and bloc in the service locator and retrieves them. Although it may appear straightforward, there are some nuances to consider.
The Service Locator pattern causes issues with testing, maintenance, and adding new features. This is because it hides a class' dependencies, leading to run-time errors instead of compile-time errors. It can also make the code harder to maintain as it becomes unclear when a breaking change is introduced.
Consider the following example of a module that uses a repository to load data:
class ModuleA {
ModuleA() {
repository = ServiceLocator.get<Repository>();
}
Repository repository;
void process() {
// ...
repository.load();
// ...
}
}The module does not directly depend on the Repository class. Instead, it uses the service locator to obtain an instance of the Repository class. This is known as dependency hiding.
The module is not aware of the dependencies it requires to function properly, making it difficult to test and reuse. Additionally, the module is tightly coupled to the service locator, meaning that if the service locator is removed, the module will no longer function.
Furthermore, determining whether a breaking change has been introduced can be difficult. For instance, if a new dependency is loaded from the service locator, it is unclear whether the module will function properly as it may not be registered. This can only be determined at runtime or through failed tests due to a missing dependency.
The Service Locator violates the Interface Segregation Principle (ISP) of the SOLID design principles. It has an infinite number of members, which forces clients to depend on members they don't need. Additionally, it gives access to every member to whatever they want, leading to tangled flows.
Consider reading following articles by Mark Seemann for better understanding:



Global scope
Global scope refers to elements such as variables, functions, or objects that are accessible from anywhere in a program, regardless of where they are initially defined. This is in stark contrast to local scope, where items are accessible only within a specific block of code, such as within a function or loop in which they are declared.
The concept of global scope is often illustrated by the use of global variables, singletons, and similar structures that promote a globally accessible state. However, this approach is generally viewed negatively in software development for several reasons.
First and foremost, it can significantly reduce the testability of software, sometimes rendering parts of it untestable. It leads to tightly coupled code, where changes in one part of the program can unexpectedly affect other parts, causing a ripple effect of problems. This lack of clarity and structure often results in software that is difficult to maintain and understand.
One of the most compelling arguments against the use of global variables, and a testament to their potential harm, is the incident involving Toyota's software. In this case, the software relied heavily on approximately 10,000 global variables. This design flaw was associated with a serious problem of unintended acceleration leading to vehicle crashes. This example highlights the risks associated with global scope, especially when it compromises the modularity and overall integrity of the code base.

I also have an article on Riverpod. Another well-known library that manages dependencies and dataflows. It relies heavily on global scope as well.
Either or Result data type
Either simply stands for a data type that is either data or error. This comes from functional languages. It is used as a replacement for the traditional throw/catch approach.
This idea only works in languages that support it natively. In particular, this means that there is no such structure as throw / catch and all APIs (even SDK) return either, as in Golang.
The thing is, if it's not in the SDK and not supported by the language itself, it just won't work as expected. The Dart code is inherently throwable - when you make a network request, the Internet connection exception may be thrown, or when you're parsing a JSON, some fields may not be provided.
In such cases it is needed to use the old good try / catch structure and then wrap the exceptions into eithers. It forces you to mix the approaches which essentially solve the same problem in different ways, and it is undesirable to use them together. Just like wearing a belt and suspenders at the same time.
Furthermore, all libraries written in Dart are throwable and expect the same from the code they work with. For example, bloc and riverpod handle errors with try / catch. This won't work for eithers.
Last but not least, Eithers tend to lose the stack trace. Stack trace is the current value of a call stack collected by the dart runtime, which is extremely useful for debugging purposes. The dart runtime automatically attaches it when you use throw, and provides a way to get it in the catch clause. However, in the either case, it is necessary to modify the exception interface so that it also carries the stack trace. This leads to complexity and potential errors & buggy code.
I firmly believe that either doesn't make life easier, code more maintainable, or simpler. In fact, it introduces new pain and doesn't solve any problems. In my opinion, it appeared due to a misunderstanding of exceptions and the throwable approach.
Consider reading the following article to learn more about error handling and either complications:

End notes
It was mainly the introductory article. At first I thought about doing one big article on all the points, but I decided to break it up into a few parts so that each topic could be covered in more depth and to keep the focus on it.
Within a week (and probably sooner) there will be another part about the data layer - data sources, repositories, their concerns. I will talk about offline-first applications, DTOs, entities, mapping and all that stuff. Hit the subscribe button to make sure you don't miss it.
