flutter
/

Modular Architecture with Bloc: Scaling Flutter Apps

Last Sync: Today

On this page

11
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

Modular Architecture with Bloc: Scaling Flutter Apps

As Flutter applications grow, maintaining a monolithic codebase becomes challenging. Modular architecture organises your app into independent, reusable modules (features), each with its own responsibilities. When combined with Bloc, this approach enables parallel development, better testability, and long‑term maintainability. This guide covers how to design and implement a modular architecture with Bloc.

What is Modular Architecture?

Modular architecture splits your application into self‑contained modules, each representing a feature or a cross‑cutting concern. Modules have clear boundaries and communicate through well‑defined interfaces. This allows teams to work independently, simplifies testing, and makes it easier to add, remove, or replace features.

Key Principles

  • Separation of concerns – Each module handles a single business domain.
  • Low coupling – Modules depend on abstractions, not concrete implementations.
  • High cohesion – Related code lives together inside the module.
  • Explicit dependencies – Modules declare what they need (e.g., repositories, services).
  • Encapsulation – Internal details of a module are not exposed outside.

Module Types

TypePurposeExamples
Feature ModuleImplements a complete feature (UI, logic, data)Auth, Home, Checkout, Profile
Shared ModuleReusable code across featuresUI components, utilities, constants
Core ModuleFoundation of the app (dependency injection, networking, database)DI container, HTTP client, local storage
Service ModuleProvides specific functionality used by featuresAnalytics, push notifications, logging

Folder Structure for Modular Apps

TEXTRead-only
1
lib/
├── main.dart                         # Entry point
├── app.dart                           # App composition
├── core/                              # Core infrastructure
│   ├── di/                            # Dependency injection setup
│   ├── network/                       # HTTP client, interceptors
│   ├── database/                      # Local storage, database clients
│   ├── navigation/                    # Router configuration
│   └── utils/                         # Shared utilities
├── shared/                            # Shared modules
│   ├── ui/                            # Reusable widgets
│   │   ├── buttons/
│   │   ├── inputs/
│   │   └── themes/
│   ├── extensions/                    # Dart extensions
│   ├── constants/                     # App-wide constants
│   └── mixins/                        # Reusable mixins
└── features/                          # Feature modules
    ├── auth/                          # Auth feature module
    │   ├── presentation/              # UI (pages, widgets)
    │   ├── bloc/                      # Feature-specific blocs
    │   ├── domain/                    # Use cases, repository interfaces
    │   └── data/                      # Repository implementations, data sources
    ├── home/                          # Home feature module
    │   ├── presentation/
    │   ├── bloc/
    │   ├── domain/
    │   └── data/
    └── checkout/                      # Checkout feature module
        ├── presentation/
        ├── bloc/
        ├── domain/
        └── data/

Implementing a Feature Module

Each feature module is self‑contained and follows the same pattern. Here’s how to structure the auth module with Bloc.

Create a public API for the module, hiding internal implementations. Use a barrel file to export only what other modules need.

DARTRead-only
1
// lib/features/auth/auth.dart
// Barrel file – public API for the auth module
export 'presentation/pages/login_page.dart';
export 'presentation/pages/register_page.dart';
export 'domain/repositories/auth_repository.dart'; // Interface only
export 'domain/usecases/login_usecase.dart';
export 'bloc/auth_bloc.dart';

Inside the module, follow the layered architecture with presentation, bloc, domain, and data. The data layer implementations are private to the module.

DARTRead-only
1
// lib/features/auth/data/repositories/auth_repository_impl.dart
// This class is not exported – only the interface is public
class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;
  // ...
}

Register the module’s dependencies in the DI container. Use a factory function that takes core dependencies and returns the module’s public instances.

DARTRead-only
1
// lib/features/auth/di/auth_module.dart
import 'package:get_it/get_it.dart';
import '../data/datasources/auth_remote_datasource.dart';
import '../data/repositories/auth_repository_impl.dart';
import '../domain/repositories/auth_repository.dart';
import '../domain/usecases/login_usecase.dart';
import '../bloc/auth_bloc.dart';

class AuthModule {
  static void register() {
    final getIt = GetIt.instance;

    // Data sources
    getIt.registerLazySingleton<AuthRemoteDataSource>(
      () => AuthRemoteDataSourceImpl(getIt<HttpClient>()),
    );

    // Repository
    getIt.registerLazySingleton<AuthRepository>(
      () => AuthRepositoryImpl(getIt<AuthRemoteDataSource>()),
    );

    // Use cases
    getIt.registerLazySingleton(() => LoginUseCase(getIt<AuthRepository>()));
    getIt.registerLazySingleton(() => LogoutUseCase(getIt<AuthRepository>()));

    // Blocs
    getIt.registerFactory(() => AuthBloc(
          loginUseCase: getIt(),
          logoutUseCase: getIt(),
        ));
  }
}
DARTRead-only
1
// lib/app.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/di/injection.dart';
import 'features/auth/auth.dart';
import 'features/home/home.dart';

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        BlocProvider(create: (_) => getIt<AuthBloc>()),
        // Other blocs
      ],
      child: MaterialApp(
        title: 'Modular App',
        initialRoute: '/',
        routes: {
          '/': (context) => const LoginPage(),
          '/home': (context) => const HomePage(),
        },
      ),
    );
  }
}

Inter-Module Communication

Modules should not directly depend on each other. Instead, they communicate through shared abstractions (interfaces) defined in the core or shared layers, or via events emitted to a global event bus.

DARTRead-only
1
// core/auth/auth_observer.dart – interface
export 'package:myapp/features/auth/domain/entities/user.dart';

abstract class AuthObserver {
  void onUserLoggedIn(User user);
  void onUserLoggedOut();
}

// In auth module:
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final AuthObserver? observer;

  AuthBloc({required this.observer, ...}) : super(...);

  void _onLoginSuccess(LoginSuccess event, Emitter<AuthState> emit) {
    observer?.onUserLoggedIn(event.user);
    // ...
  }
}

// In home module:
class HomeBloc extends Bloc<HomeEvent, HomeState> implements AuthObserver {
  // ...
  @override
  void onUserLoggedIn(User user) {
    add(UserLoggedIn(user));
  }
}

// In app composition:
final authBloc = AuthBloc(observer: homeBloc, ...);

A global event bus (e.g., EventBus package) allows loosely coupled communication between modules.

DARTRead-only
1
// core/events/auth_events.dart
class UserLoggedInEvent {
  final User user;
  UserLoggedInEvent(this.user);
}

// In auth bloc:
void _onLoginSuccess(LoginSuccess event, Emitter<AuthState> emit) {
  getIt<EventBus>().fire(UserLoggedInEvent(event.user));
}

// In home bloc:
HomeBloc() {
  getIt<EventBus>().on<UserLoggedInEvent>().listen((event) {
    add(UserLoggedIn(event.user));
  });
}

Navigation Between Modules

Modular apps often use a centralised router (like go_router) that knows how to build pages for each module. Each module can provide its own routes without exposing its widgets directly.

DARTRead-only
1
// lib/features/auth/auth_routes.dart
import 'package:go_router/go_router.dart';
import 'presentation/pages/login_page.dart';

class AuthRoutes {
  static const String login = '/login';
  static const String register = '/register';

  static GoRoute get loginRoute => GoRoute(
        path: login,
        builder: (context, state) => const LoginPage(),
      );

  static GoRoute get registerRoute => GoRoute(
        path: register,
        builder: (context, state) => const RegisterPage(),
      );
}

// lib/core/navigation/app_router.dart
import 'package:go_router/go_router.dart';
import '../../features/auth/auth_routes.dart';
import '../../features/home/home_routes.dart';

final appRouter = GoRouter(
  initialLocation: AuthRoutes.login,
  routes: [
    AuthRoutes.loginRoute,
    AuthRoutes.registerRoute,
    HomeRoutes.homeRoute,
    // ...
  ],
);

Testing Modular Architecture

Modularisation greatly simplifies testing. You can test each module in isolation by mocking its dependencies (including other modules' interfaces).

DARTRead-only
1
// test/features/auth/bloc/auth_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:mocktail/mocktail.dart';

class MockLoginUseCase extends Mock implements LoginUseCase {}

void main() {
  late MockLoginUseCase mockLoginUseCase;
  late AuthBloc bloc;

  setUp(() {
    mockLoginUseCase = MockLoginUseCase();
    bloc = AuthBloc(
      loginUseCase: mockLoginUseCase,
      logoutUseCase: MockLogoutUseCase(),
    );
  });

  blocTest<AuthBloc, AuthState>(
    'emits loading and success when login succeeds',
    build: () => bloc,
    act: (bloc) {
      when(() => mockLoginUseCase.execute(any(), any()))
          .thenAnswer((_) async => Right(mockUser));
      bloc.add(LoginRequested('test@example.com', 'password'));
    },
    expect: () => [AuthLoading(), AuthSuccess(mockUser)],
  );
}

Best Practices

  • Define clear boundaries – Each module should have a single responsibility and a well‑defined public API.
  • Use dependency injection – Register module dependencies in a DI container to avoid hard‑coding references.
  • Keep modules independent – A module should be testable without needing other modules to be present.
  • Share only through interfaces – Other modules should depend on abstractions, not concrete implementations.
  • Automate module registration – Use code generation (e.g., injectable) or manual registration scripts to avoid boilerplate.
  • Use a consistent structure – Every feature module should follow the same folder and naming conventions.
  • Lazy‑load modules – Use deferred imports or package features to reduce initial bundle size.

Common Mistakes

  • ❌ Cyclic dependencies – Module A depends on Module B, and Module B depends on Module A. Solve by extracting shared code to a lower layer.
  • ❌ Tight coupling – One module directly imports another module's internal classes. Use public APIs only.
  • ❌ Over‑modularisation – Creating modules for everything (e.g., a single button) adds unnecessary complexity.
  • ❌ Not versioning modules – In large teams, module versioning (e.g., via pubspec) helps manage breaking changes.
  • ❌ Ignoring build times – Many modules can increase build times; consider using flutter pub workspaces or modularisation tools.

What's Next?

Modular architecture sets the foundation for scalable apps. Next, learn how to implement each module with Clean Architecture and use cases, and how to handle module communication with advanced patterns.

Next, explore Clean Architecture with Bloc and Use case layer.

Test Your Knowledge

Q1
of 3

What is the main benefit of modular architecture with Bloc?

A
Faster compilation time
B
Separation of concerns and scalability
C
Automatic state persistence
D
Built-in network handling
Q2
of 3

How should modules communicate with each other?

A
By directly importing each other's code
B
Through shared abstractions or event buses
C
Only via global variables
D
Modules should never communicate
Q3
of 3

Which of the following is NOT a recommended module type?

A
Feature module
B
Core module
C
Shared module
D
Singleton module

Frequently Asked Questions

When should I start modularising my Flutter app?

Modular architecture is most beneficial for medium to large apps (10+ screens, multiple developers). For small apps, a simpler structure is fine. You can start with a feature-based folder structure and refactor to proper modules as the app grows.

How do I share a bloc between modules?

Blocs are usually scoped to a feature. If a bloc needs to be shared (e.g., AuthBloc), provide it at the top level (above the router) so all modules can access it via context.read.

Can I use feature modules as separate Flutter packages?

Yes, you can create each feature as a separate Dart package in a monorepo (using melos or pub workspaces). This enforces strict boundaries and allows independent versioning. However, it adds build complexity and is best for very large projects.

How do I handle feature flags in a modular app?

Use a core configuration module that exposes feature flags. Each module can check these flags to enable/disable features. Alternatively, use a dependency injection container that registers different implementations based on flags.

Previous

bloc feature based structure

Next

bloc multi bloc communication

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.