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() {
        // ...
    }
}

Read more