General Architectural and Design Patterns in Flutter

When building Flutter applications, choosing the right architectural and design patterns is essential for a maintainable, scalable, testable codebase. This article explores some of these patterns and their implementations.

Architectural Patterns

1. Clean Architecture

Clean architecture is one of Flutter application’s most popular and widely used architectural patterns. It relies on separating code into layers, thereby ensuring separation of concerns and maintainability.

lib/
├── data/                     // Outer layer
│   ├── datasources/
│   │   ├── remote/
│   │   │   └── api_client.dart
│   │   └── local/
│   │       └── local_storage.dart
│   ├── models/
│   │   ├── user_model.dart
│   │   └── product_model.dart
│   └── repositories/
│       ├── user_repository_impl.dart
│       └── product_repository_impl.dart
├── domain/                   // Core layer
│   ├── entities/
│   │   ├── user.dart
│   │   └── product.dart
│   ├── repositories/
│   │   ├── user_repository.dart
│   │   └── product_repository.dart
│   └── usecases/
│       ├── get_user.dart
│       └── get_products.dart
└── presentation/            // UI layer
    ├── bloc/
    │   ├── user_bloc.dart
    │   └── product_bloc.dart
    ├── pages/
    │   ├── user_page.dart
    │   └── product_page.dart
    └── widgets/
        ├── user_card.dart
        └── product_card.dart

Since the code is loosely coupled, it’s easier to make changes to any layer/section of the code. The code is also easier to read and test.

A. Organisation by Layer

  • All data layers for features are coded together
  • All domain layers for features are coded together
  • All presentation layers for features are coded together

B. Dependencies

// Flows from outer to inner layers
presentation -> domain <- data

C. Advantages

  • Clear separation of concerns
  • Every layer’s responsibility is defined
  • Consistent Structure

2. Feature-First Architecture

Feature-First architecture organizes code based on business features rather than technical layers. This approach provides better scalability, maintainability, and team collaboration. This architecture also uses clean architecture but instead of using clean architecture on the whole project, it is applied to individual features.

lib/
├── core/                    // Shared code
│   ├── network/
│   │   └── api_client.dart
│   ├── storage/
│   │   └── local_storage.dart
│   └── utils/
│       └── constants.dart
└── features/
    ├── auth/               // Auth feature
    │   ├── data/
    │   │   ├── datasources/
    │   │   ├── models/
    │   │   │   └── user_model.dart
    │   │   └── repositories/
    │   │       └── auth_repository_impl.dart
    │   ├── domain/
    │   │   ├── entities/
    │   │   │   └── user.dart
    │   │   ├── repositories/
    │   │   │   └── auth_repository.dart
    │   │   └── usecases/
    │   │       ├── login.dart
    │   │       └── logout.dart
    │   └── presentation/
    │       ├── bloc/
    │       │   └── auth_bloc.dart
    │       ├── pages/
    │       │   └── login_page.dart
    │       └── widgets/
    │           └── login_form.dart
    └── products/           // Products feature
        ├── data/
        │   ├── datasources/
        │   ├── models/
        │   │   └── product_model.dart
        │   └── repositories/
        │       └── product_repository_impl.dart
        ├── domain/
        │   ├── entities/
        │   │   └── product.dart
        │   ├── repositories/
        │   │   └── product_repository.dart
        │   └── usecases/
        │       └── get_products.dart
        └── presentation/
            ├── bloc/
            │   └── product_bloc.dart
            ├── pages/
            │   └── products_page.dart
            └── widgets/
                └── product_card.dart

A. Organisation by feature

  • Clean architecture is applied per feature
  • Shared code is in the core module

B. Dependencies

// Each feature has its own clean architecture
feature/
    presentation -> domain <- data

C. Advantages

  • Better feature isolation
  • Better maintainable code
  • Easier team collaboration

Design Patterns

1. Singleton Pattern

Singleton pattern ensures that only a single instance of a class exists throughout the application.

class ApiService {
    static final ApiService _instance = ApiService._internal();
    
    // Factory constructor
    factory ApiService() {
        return _instance;
    }
    
    // Private constructor
    ApiService._internal();
    
    Future<dynamic> get(String endpoint) async {
        // API implementation
    }
}

// Usage
final apiService = ApiService(); // Same instance everywhere

2. Factory Pattern

Factory patterns create objects without exposing the creation logic.

abstract class Button {
    void render();
}

class AndroidButton implements Button {
    @override
    void render() {
        print('Rendering Android button');
    }
}

class IOSButton implements Button {
    @override
    void render() {
        print('Rendering iOS button');
    }
}

class ButtonFactory {
    static Button createButton(TargetPlatform platform) {
        switch (platform) {
            case TargetPlatform.android:
                return AndroidButton();
            case TargetPlatform.iOS:
                return IOSButton();
            default:
                throw UnsupportedError('Platform not supported');
        }
    }
}

3. Repository Pattern

Repository pattern abstracts data source details from the business logic layer.

// Repository interface
abstract class UserRepository {
    Future<User> getUser(String id);
    Future<void> saveUser(User user);
}

// Implementation
class UserRepositoryImpl implements UserRepository {
    final ApiClient _apiClient;
    final LocalStorage _storage;

    UserRepositoryImpl(this._apiClient, this._storage);

    @override
    Future<User> getUser(String id) async {
        try {
            return await _storage.getUser(id) ?? 
                   await _apiClient.fetchUser(id);
        } catch (e) {
            throw UserException('Failed to get user');
        }
    }
}

4. BLoC pattern

BLoC pattern separates business logic from UI layer.

// Events
abstract class AuthEvent {}
class LoginRequested extends AuthEvent {
    final String username;
    final String password;
    
    LoginRequested(this.username, this.password);
}

// States
abstract class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthSuccess extends AuthState {}

// BLoC
class AuthBloc extends Bloc<AuthEvent, AuthState> {
    final AuthRepository _repository;

    AuthBloc(this._repository) : super(AuthInitial()) {
        on<LoginRequested>((event, emit) async {
            emit(AuthLoading());
            try {
                await _repository.login(
                    event.username, 
                    event.password
                );
                emit(AuthSuccess());
            } catch (e) {
                emit(AuthFailure(e.toString()));
            }
        });
    }
}

4. Observer Pattern

Observer pattern is used to implement a subscription mechanism for state changes

class DataStore {
    final _controller = StreamController<String>.broadcast();
    Stream<String> get dataStream => _controller.stream;

    void updateData(String newData) {
        _controller.add(newData);
    }

    void dispose() {
        _controller.close();
    }
}

// Usage in Widget
class DataWidget extends StatelessWidget {
    final DataStore _store = DataStore();

    @override
    Widget build(BuildContext context) {
        return StreamBuilder<String>(
            stream: _store.dataStream,
            builder: (context, snapshot) {
                return Text(snapshot.data ?? '');
            },
        );
    }
}

Best Practices

1. Choose Appropriate Patterns

  • Consider App size, complexity, and your team’s size
  • Consider maintenance requirements

2. Combine Patterns

  • Use singleton or factory pattern when creating core components
  • Combine BLoC pattern with Repository pattern for domain and data layer separation.

3. Keep It Simple

  • Add documentation wherever possible
  • Don’t over-engineer, use patterns only when required

These architectural and design patterns in Flutter will help in writing code that is maintainable, testable, and scalable. Any codebase with sound architecture and design patterns will be easier for a team of developers to maintain. You can check out this Reddit discussion on what other developers think about the best architecture and design patterns. You should also look at some advanced Flutter optimization techniques next.

You may also like...

Leave a Reply

Your email address will not be published. Required fields are marked *