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
| Type | Purpose | Examples |
|---|
| Feature Module | Implements a complete feature (UI, logic, data) | Auth, Home, Checkout, Profile |
| Shared Module | Reusable code across features | UI components, utilities, constants |
| Core Module | Foundation of the app (dependency injection, networking, database) | DI container, HTTP client, local storage |
| Service Module | Provides specific functionality used by features | Analytics, push notifications, logging |
Folder Structure for Modular Apps
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.
export 'presentation/pages/login_page.dart';
export 'presentation/pages/register_page.dart';
export 'domain/repositories/auth_repository.dart';
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.
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.
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;
getIt.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(getIt<HttpClient>()),
);
getIt.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(getIt<AuthRemoteDataSource>()),
);
getIt.registerLazySingleton(() => LoginUseCase(getIt<AuthRepository>()));
getIt.registerLazySingleton(() => LogoutUseCase(getIt<AuthRepository>()));
getIt.registerFactory(() => AuthBloc(
loginUseCase: getIt(),
logoutUseCase: getIt(),
));
}
}
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>()),
],
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.
export 'package:myapp/features/auth/domain/entities/user.dart';
abstract class AuthObserver {
void onUserLoggedIn(User user);
void onUserLoggedOut();
}
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);
}
}
class HomeBloc extends Bloc<HomeEvent, HomeState> implements AuthObserver {
@override
void onUserLoggedIn(User user) {
add(UserLoggedIn(user));
}
}
final authBloc = AuthBloc(observer: homeBloc, ...);
A global event bus (e.g., EventBus package) allows loosely coupled communication between modules.
class UserLoggedInEvent {
final User user;
UserLoggedInEvent(this.user);
}
void _onLoginSuccess(LoginSuccess event, Emitter<AuthState> emit) {
getIt<EventBus>().fire(UserLoggedInEvent(event.user));
}
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.
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(),
);
}
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).
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.