Flutter App Architecture - Patterns, Principles and Anti-Patterns

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.

  1. Principles and Patterns
    1. Separation of Concerns
    2. SOLID
    3. Coupling and Cohesion
    4. Dependency Injection
  2. Anti-Patterns
    1. Service Locator
    2. Global Scope
    3. Either or Result data type

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:

  1. Presentation (widgets): Responsible for the user interface and interaction with user.
  2. Business Logic: Contains core functionality and rules of the application.
  3. 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:

Deep Dive into SOLID Principles
This article is about principles that are likely to improve your overall architectural skills and knowledge. The article is supercharged with real world examples and the power of DART language! Let’s go! Good architecture Most of us have wondered what good architecture is. The answers are complex, but it is

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:

Service Locator is an Anti-Pattern
While Service Locator was originally described as a pattern, it’s actually an anti-pattern.
Service Locator violates SOLID
Yet another reason to avoid the Service Locator anti-pattern is that it violates the principles of Object-Oriented Design.
Service Locator violates encapsulation
Service Locator violates encapsulation in statically typed languages because it doesn’t clearly communicate preconditions.

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.

On August 28, 2009, Mark Saylor, his wife Cleofe Saylor, their thirteen-year-old daughter Mahala Saylor and Cleofe's brother Chris Lastrella died when the Saylor's 2009 Lexus ES 350 crashed. The accident happened in Santee, California, when the car accelerated and wouldn't respond. (Detroit Free Press/MCT)

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:

Mastering Error Handling in Dart
Explore Dart error handling essentials: exceptions vs. errors, try/catch best practices, stack trace management and why either is bad.

End notes

low angle view of ball shoots in the ring
Photo by Aline de Nadai / Unsplash

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.