Layer Communication: How Does Dependency Injection Work in Flutter Apps?


Introduction
Introduction
Introduction
Introduction
In Flutter app development, a well-structured architecture isn't just about organizing files—it's about how each layer communicates effectively without becoming tightly coupled. This blog explores how different layers of a Flutter app—such as UI, data, and logic—can talk to each other using clean, maintainable patterns. A core concept in achieving this is Dependency Injection (DI), which helps inject services, repositories, or logic layers into each other without hardcoding dependencies.
This blog explores how different layers in a Flutter app communicate and remain decoupled using Dependency Injection. We’ll cover core principles, tools, and patterns to keep your architecture clean and testable.
We’ll dive into the principles of clean communication, the importance of unidirectional data flow, and the use of DI tools like Provider
, Riverpod
, and get_it
. You’ll also see code examples for setting up DI and managing service lifecycles. This guide ensures your architecture remains scalable, testable, and easy to extend.
Ready to build a loosely-coupled Flutter app architecture? Let’s dive into DI and master clean communication between layers.
In Flutter app development, a well-structured architecture isn't just about organizing files—it's about how each layer communicates effectively without becoming tightly coupled. This blog explores how different layers of a Flutter app—such as UI, data, and logic—can talk to each other using clean, maintainable patterns. A core concept in achieving this is Dependency Injection (DI), which helps inject services, repositories, or logic layers into each other without hardcoding dependencies.
This blog explores how different layers in a Flutter app communicate and remain decoupled using Dependency Injection. We’ll cover core principles, tools, and patterns to keep your architecture clean and testable.
We’ll dive into the principles of clean communication, the importance of unidirectional data flow, and the use of DI tools like Provider
, Riverpod
, and get_it
. You’ll also see code examples for setting up DI and managing service lifecycles. This guide ensures your architecture remains scalable, testable, and easy to extend.
Ready to build a loosely-coupled Flutter app architecture? Let’s dive into DI and master clean communication between layers.
In Flutter app development, a well-structured architecture isn't just about organizing files—it's about how each layer communicates effectively without becoming tightly coupled. This blog explores how different layers of a Flutter app—such as UI, data, and logic—can talk to each other using clean, maintainable patterns. A core concept in achieving this is Dependency Injection (DI), which helps inject services, repositories, or logic layers into each other without hardcoding dependencies.
This blog explores how different layers in a Flutter app communicate and remain decoupled using Dependency Injection. We’ll cover core principles, tools, and patterns to keep your architecture clean and testable.
We’ll dive into the principles of clean communication, the importance of unidirectional data flow, and the use of DI tools like Provider
, Riverpod
, and get_it
. You’ll also see code examples for setting up DI and managing service lifecycles. This guide ensures your architecture remains scalable, testable, and easy to extend.
Ready to build a loosely-coupled Flutter app architecture? Let’s dive into DI and master clean communication between layers.
In Flutter app development, a well-structured architecture isn't just about organizing files—it's about how each layer communicates effectively without becoming tightly coupled. This blog explores how different layers of a Flutter app—such as UI, data, and logic—can talk to each other using clean, maintainable patterns. A core concept in achieving this is Dependency Injection (DI), which helps inject services, repositories, or logic layers into each other without hardcoding dependencies.
This blog explores how different layers in a Flutter app communicate and remain decoupled using Dependency Injection. We’ll cover core principles, tools, and patterns to keep your architecture clean and testable.
We’ll dive into the principles of clean communication, the importance of unidirectional data flow, and the use of DI tools like Provider
, Riverpod
, and get_it
. You’ll also see code examples for setting up DI and managing service lifecycles. This guide ensures your architecture remains scalable, testable, and easy to extend.
Ready to build a loosely-coupled Flutter app architecture? Let’s dive into DI and master clean communication between layers.
Understanding Layer Communication Rules
Understanding Layer Communication Rules
Understanding Layer Communication Rules
Understanding Layer Communication Rules
In a layered Flutter architecture, defining clear responsibilities for each component is crucial—but equally important is how these components communicate with one another. This involves two key aspects:
The rules that govern which layers can talk to which.
The technical implementation that enables this communication.

Your app’s architecture should provide clear answers to questions like:
Which components are permitted to communicate with others (even within the same layer)?
What outputs do these components expose to enable this communication?
How is one layer connected or injected into another in a clean, maintainable way?
Rules for Communication Between Architecture Layers
To visualize this, consider a simplified architecture diagram. It illustrates how different parts of your app are organized and how they interact.
Using that structure as a reference, we’ll now break down the rules of engagement between layers to ensure a scalable and testable app foundation.
Component | Rules of Engagement |
---|---|
View | - Only aware of a single ViewModel. - Receives the ViewModel through the constructor or DI. - Never directly accesses repositories or services. |
ViewModel | - Belongs to one View. - Exposes data and commands to the View. - Aware of one or more repositories, passed into its constructor. - Never knows about the View itself. |
Repository | - Knows about one or more Services. - Receives Services via constructor injection. - Can be reused across multiple ViewModels. - Remains unaware of ViewModels. |
Service | - Provides low-level data operations (e.g., network, storage). - Used by many repositories. - Completely unaware of who uses it. |
In a layered Flutter architecture, defining clear responsibilities for each component is crucial—but equally important is how these components communicate with one another. This involves two key aspects:
The rules that govern which layers can talk to which.
The technical implementation that enables this communication.

Your app’s architecture should provide clear answers to questions like:
Which components are permitted to communicate with others (even within the same layer)?
What outputs do these components expose to enable this communication?
How is one layer connected or injected into another in a clean, maintainable way?
Rules for Communication Between Architecture Layers
To visualize this, consider a simplified architecture diagram. It illustrates how different parts of your app are organized and how they interact.
Using that structure as a reference, we’ll now break down the rules of engagement between layers to ensure a scalable and testable app foundation.
Component | Rules of Engagement |
---|---|
View | - Only aware of a single ViewModel. - Receives the ViewModel through the constructor or DI. - Never directly accesses repositories or services. |
ViewModel | - Belongs to one View. - Exposes data and commands to the View. - Aware of one or more repositories, passed into its constructor. - Never knows about the View itself. |
Repository | - Knows about one or more Services. - Receives Services via constructor injection. - Can be reused across multiple ViewModels. - Remains unaware of ViewModels. |
Service | - Provides low-level data operations (e.g., network, storage). - Used by many repositories. - Completely unaware of who uses it. |
In a layered Flutter architecture, defining clear responsibilities for each component is crucial—but equally important is how these components communicate with one another. This involves two key aspects:
The rules that govern which layers can talk to which.
The technical implementation that enables this communication.

Your app’s architecture should provide clear answers to questions like:
Which components are permitted to communicate with others (even within the same layer)?
What outputs do these components expose to enable this communication?
How is one layer connected or injected into another in a clean, maintainable way?
Rules for Communication Between Architecture Layers
To visualize this, consider a simplified architecture diagram. It illustrates how different parts of your app are organized and how they interact.
Using that structure as a reference, we’ll now break down the rules of engagement between layers to ensure a scalable and testable app foundation.
Component | Rules of Engagement |
---|---|
View | - Only aware of a single ViewModel. - Receives the ViewModel through the constructor or DI. - Never directly accesses repositories or services. |
ViewModel | - Belongs to one View. - Exposes data and commands to the View. - Aware of one or more repositories, passed into its constructor. - Never knows about the View itself. |
Repository | - Knows about one or more Services. - Receives Services via constructor injection. - Can be reused across multiple ViewModels. - Remains unaware of ViewModels. |
Service | - Provides low-level data operations (e.g., network, storage). - Used by many repositories. - Completely unaware of who uses it. |
In a layered Flutter architecture, defining clear responsibilities for each component is crucial—but equally important is how these components communicate with one another. This involves two key aspects:
The rules that govern which layers can talk to which.
The technical implementation that enables this communication.

Your app’s architecture should provide clear answers to questions like:
Which components are permitted to communicate with others (even within the same layer)?
What outputs do these components expose to enable this communication?
How is one layer connected or injected into another in a clean, maintainable way?
Rules for Communication Between Architecture Layers
To visualize this, consider a simplified architecture diagram. It illustrates how different parts of your app are organized and how they interact.
Using that structure as a reference, we’ll now break down the rules of engagement between layers to ensure a scalable and testable app foundation.
Component | Rules of Engagement |
---|---|
View | - Only aware of a single ViewModel. - Receives the ViewModel through the constructor or DI. - Never directly accesses repositories or services. |
ViewModel | - Belongs to one View. - Exposes data and commands to the View. - Aware of one or more repositories, passed into its constructor. - Never knows about the View itself. |
Repository | - Knows about one or more Services. - Receives Services via constructor injection. - Can be reused across multiple ViewModels. - Remains unaware of ViewModels. |
Service | - Provides low-level data operations (e.g., network, storage). - Used by many repositories. - Completely unaware of who uses it. |
What Is Dependency Injection in Flutter?
What Is Dependency Injection in Flutter?
What Is Dependency Injection in Flutter?
What Is Dependency Injection in Flutter?
So far, we’ve seen how different architectural layers communicate using constructor-based inputs and outputs. For instance, a Service
is passed into a Repository
, which is then passed into a ViewModel
. But that raises an important question:
Where and how are these objects created?
This is where Dependency Injection (DI) comes into play, a design pattern that manages the creation and delivery of objects your classes depend on.
Constructor Injection in Practice
Here's an example of how constructor-based injection works in a Repository
:
class MyRepository {
MyRepository({required MyService myService})
: _myService = myService;
late final MyService _myService;
}
The MyRepository
expects an instance of MyService
when it's created. This keeps it flexible and easy to test, but the responsibility of creating and providing that instance now falls to a higher level in your app.
So far, we’ve seen how different architectural layers communicate using constructor-based inputs and outputs. For instance, a Service
is passed into a Repository
, which is then passed into a ViewModel
. But that raises an important question:
Where and how are these objects created?
This is where Dependency Injection (DI) comes into play, a design pattern that manages the creation and delivery of objects your classes depend on.
Constructor Injection in Practice
Here's an example of how constructor-based injection works in a Repository
:
class MyRepository {
MyRepository({required MyService myService})
: _myService = myService;
late final MyService _myService;
}
The MyRepository
expects an instance of MyService
when it's created. This keeps it flexible and easy to test, but the responsibility of creating and providing that instance now falls to a higher level in your app.
So far, we’ve seen how different architectural layers communicate using constructor-based inputs and outputs. For instance, a Service
is passed into a Repository
, which is then passed into a ViewModel
. But that raises an important question:
Where and how are these objects created?
This is where Dependency Injection (DI) comes into play, a design pattern that manages the creation and delivery of objects your classes depend on.
Constructor Injection in Practice
Here's an example of how constructor-based injection works in a Repository
:
class MyRepository {
MyRepository({required MyService myService})
: _myService = myService;
late final MyService _myService;
}
The MyRepository
expects an instance of MyService
when it's created. This keeps it flexible and easy to test, but the responsibility of creating and providing that instance now falls to a higher level in your app.
So far, we’ve seen how different architectural layers communicate using constructor-based inputs and outputs. For instance, a Service
is passed into a Repository
, which is then passed into a ViewModel
. But that raises an important question:
Where and how are these objects created?
This is where Dependency Injection (DI) comes into play, a design pattern that manages the creation and delivery of objects your classes depend on.
Constructor Injection in Practice
Here's an example of how constructor-based injection works in a Repository
:
class MyRepository {
MyRepository({required MyService myService})
: _myService = myService;
late final MyService _myService;
}
The MyRepository
expects an instance of MyService
when it's created. This keeps it flexible and easy to test, but the responsibility of creating and providing that instance now falls to a higher level in your app.
Implementing DI with provider
Implementing DI with provider
Implementing DI with provider
Implementing DI with provider
In the Compass app, DI is handled using package:provider
, a recommended approach by the Flutter team at Google. It allows services and repositories to be injected into the widget tree and accessed cleanly where needed.
Here’s how a typical DI setup looks using MultiProvider
:
runApp(
MultiProvider(
providers: [
Provider(create: (context) => AuthApiClient()),
Provider(create: (context) => ApiClient()),
Provider(create: (context) => SharedPreferencesService()),
ChangeNotifierProvider(
create: (context) => AuthRepositoryRemote(
authApiClient: context.read(),
apiClient: context.read(),
sharedPreferencesService: context.read(),
) as AuthRepository,
),
Provider(create: (context) =>
DestinationRepositoryRemote(
apiClient: context.read(),
) as DestinationRepository,
),
Provider(create: (context) =>
ContinentRepositoryRemote(
apiClient: context.read(),
) as ContinentRepository,
),
// More repositories or services can be added here
],
child: const MainApp(),
),
);
Each service or repository is registered at the top level and can be accessed throughout the widget tree using context.read<T>()
or context.watch<T>()
. This pattern keeps your app cleanly decoupled, testable, and easy to scale.
✅ Why Use Dependency Injection?
Promotes modular architecture
Simplifies unit testing (easier to mock)
Prevents tight coupling between layers
Makes your app easier to scale and maintain
In the next section, we’ll look at best practices for managing dependencies and ensuring your architecture stays clean, regardless of app size.
In the Compass app, DI is handled using package:provider
, a recommended approach by the Flutter team at Google. It allows services and repositories to be injected into the widget tree and accessed cleanly where needed.
Here’s how a typical DI setup looks using MultiProvider
:
runApp(
MultiProvider(
providers: [
Provider(create: (context) => AuthApiClient()),
Provider(create: (context) => ApiClient()),
Provider(create: (context) => SharedPreferencesService()),
ChangeNotifierProvider(
create: (context) => AuthRepositoryRemote(
authApiClient: context.read(),
apiClient: context.read(),
sharedPreferencesService: context.read(),
) as AuthRepository,
),
Provider(create: (context) =>
DestinationRepositoryRemote(
apiClient: context.read(),
) as DestinationRepository,
),
Provider(create: (context) =>
ContinentRepositoryRemote(
apiClient: context.read(),
) as ContinentRepository,
),
// More repositories or services can be added here
],
child: const MainApp(),
),
);
Each service or repository is registered at the top level and can be accessed throughout the widget tree using context.read<T>()
or context.watch<T>()
. This pattern keeps your app cleanly decoupled, testable, and easy to scale.
✅ Why Use Dependency Injection?
Promotes modular architecture
Simplifies unit testing (easier to mock)
Prevents tight coupling between layers
Makes your app easier to scale and maintain
In the next section, we’ll look at best practices for managing dependencies and ensuring your architecture stays clean, regardless of app size.
In the Compass app, DI is handled using package:provider
, a recommended approach by the Flutter team at Google. It allows services and repositories to be injected into the widget tree and accessed cleanly where needed.
Here’s how a typical DI setup looks using MultiProvider
:
runApp(
MultiProvider(
providers: [
Provider(create: (context) => AuthApiClient()),
Provider(create: (context) => ApiClient()),
Provider(create: (context) => SharedPreferencesService()),
ChangeNotifierProvider(
create: (context) => AuthRepositoryRemote(
authApiClient: context.read(),
apiClient: context.read(),
sharedPreferencesService: context.read(),
) as AuthRepository,
),
Provider(create: (context) =>
DestinationRepositoryRemote(
apiClient: context.read(),
) as DestinationRepository,
),
Provider(create: (context) =>
ContinentRepositoryRemote(
apiClient: context.read(),
) as ContinentRepository,
),
// More repositories or services can be added here
],
child: const MainApp(),
),
);
Each service or repository is registered at the top level and can be accessed throughout the widget tree using context.read<T>()
or context.watch<T>()
. This pattern keeps your app cleanly decoupled, testable, and easy to scale.
✅ Why Use Dependency Injection?
Promotes modular architecture
Simplifies unit testing (easier to mock)
Prevents tight coupling between layers
Makes your app easier to scale and maintain
In the next section, we’ll look at best practices for managing dependencies and ensuring your architecture stays clean, regardless of app size.
In the Compass app, DI is handled using package:provider
, a recommended approach by the Flutter team at Google. It allows services and repositories to be injected into the widget tree and accessed cleanly where needed.
Here’s how a typical DI setup looks using MultiProvider
:
runApp(
MultiProvider(
providers: [
Provider(create: (context) => AuthApiClient()),
Provider(create: (context) => ApiClient()),
Provider(create: (context) => SharedPreferencesService()),
ChangeNotifierProvider(
create: (context) => AuthRepositoryRemote(
authApiClient: context.read(),
apiClient: context.read(),
sharedPreferencesService: context.read(),
) as AuthRepository,
),
Provider(create: (context) =>
DestinationRepositoryRemote(
apiClient: context.read(),
) as DestinationRepository,
),
Provider(create: (context) =>
ContinentRepositoryRemote(
apiClient: context.read(),
) as ContinentRepository,
),
// More repositories or services can be added here
],
child: const MainApp(),
),
);
Each service or repository is registered at the top level and can be accessed throughout the widget tree using context.read<T>()
or context.watch<T>()
. This pattern keeps your app cleanly decoupled, testable, and easy to scale.
✅ Why Use Dependency Injection?
Promotes modular architecture
Simplifies unit testing (easier to mock)
Prevents tight coupling between layers
Makes your app easier to scale and maintain
In the next section, we’ll look at best practices for managing dependencies and ensuring your architecture stays clean, regardless of app size.
Injecting ViewModels via GoRouter and Provider
Injecting ViewModels via GoRouter and Provider
Injecting ViewModels via GoRouter and Provider
Injecting ViewModels via GoRouter and Provider
In our architecture, services are exposed only so they can be immediately injected into repositories using context.read()
the provider
package. These repositories are then made available to view models, which power the business logic behind each screen.
🧭 Wiring ViewModels Using go_router
A layer deeper into the widget tree, view models are often instantiated per screen using the go_router
configuration. Here, provider
it is once again used to inject repositories into view models.
Take a look at how the GoRouter
is set up to inject dependencies into each screen:
// router.dart (simplified for demo)
GoRouter router(AuthRepository authRepository) => GoRouter(
initialLocation: Routes.home,
debugLogDiagnostics: true,
redirect: _redirect,
refreshListenable: authRepository,
routes: [
GoRoute(
path: Routes.login,
builder: (context, state) {
return LoginScreen(
viewModel: LoginViewModel(
authRepository: context.read(),
),
);
},
),
GoRoute(
path: Routes.home,
builder: (context, state) {
final viewModel = HomeViewModel(
bookingRepository: context.read(),
);
return HomeScreen(viewModel: viewModel);
},
),
],
);
Why This Approach Works Well
Keeps ViewModel creation close to the screen that needs it.
Maintains constructor-based dependency injection, ideal for testing.
Avoids global state by leveraging context-scoped objects.
Keeps logic out of widgets and focuses on composition.
By using go_router
and provider
Together, you achieve clean navigation while maintaining modular architecture. Each screen owns its ViewModel, and each ViewModel receives its dependencies from the surrounding widget tree.
In our architecture, services are exposed only so they can be immediately injected into repositories using context.read()
the provider
package. These repositories are then made available to view models, which power the business logic behind each screen.
🧭 Wiring ViewModels Using go_router
A layer deeper into the widget tree, view models are often instantiated per screen using the go_router
configuration. Here, provider
it is once again used to inject repositories into view models.
Take a look at how the GoRouter
is set up to inject dependencies into each screen:
// router.dart (simplified for demo)
GoRouter router(AuthRepository authRepository) => GoRouter(
initialLocation: Routes.home,
debugLogDiagnostics: true,
redirect: _redirect,
refreshListenable: authRepository,
routes: [
GoRoute(
path: Routes.login,
builder: (context, state) {
return LoginScreen(
viewModel: LoginViewModel(
authRepository: context.read(),
),
);
},
),
GoRoute(
path: Routes.home,
builder: (context, state) {
final viewModel = HomeViewModel(
bookingRepository: context.read(),
);
return HomeScreen(viewModel: viewModel);
},
),
],
);
Why This Approach Works Well
Keeps ViewModel creation close to the screen that needs it.
Maintains constructor-based dependency injection, ideal for testing.
Avoids global state by leveraging context-scoped objects.
Keeps logic out of widgets and focuses on composition.
By using go_router
and provider
Together, you achieve clean navigation while maintaining modular architecture. Each screen owns its ViewModel, and each ViewModel receives its dependencies from the surrounding widget tree.
In our architecture, services are exposed only so they can be immediately injected into repositories using context.read()
the provider
package. These repositories are then made available to view models, which power the business logic behind each screen.
🧭 Wiring ViewModels Using go_router
A layer deeper into the widget tree, view models are often instantiated per screen using the go_router
configuration. Here, provider
it is once again used to inject repositories into view models.
Take a look at how the GoRouter
is set up to inject dependencies into each screen:
// router.dart (simplified for demo)
GoRouter router(AuthRepository authRepository) => GoRouter(
initialLocation: Routes.home,
debugLogDiagnostics: true,
redirect: _redirect,
refreshListenable: authRepository,
routes: [
GoRoute(
path: Routes.login,
builder: (context, state) {
return LoginScreen(
viewModel: LoginViewModel(
authRepository: context.read(),
),
);
},
),
GoRoute(
path: Routes.home,
builder: (context, state) {
final viewModel = HomeViewModel(
bookingRepository: context.read(),
);
return HomeScreen(viewModel: viewModel);
},
),
],
);
Why This Approach Works Well
Keeps ViewModel creation close to the screen that needs it.
Maintains constructor-based dependency injection, ideal for testing.
Avoids global state by leveraging context-scoped objects.
Keeps logic out of widgets and focuses on composition.
By using go_router
and provider
Together, you achieve clean navigation while maintaining modular architecture. Each screen owns its ViewModel, and each ViewModel receives its dependencies from the surrounding widget tree.
In our architecture, services are exposed only so they can be immediately injected into repositories using context.read()
the provider
package. These repositories are then made available to view models, which power the business logic behind each screen.
🧭 Wiring ViewModels Using go_router
A layer deeper into the widget tree, view models are often instantiated per screen using the go_router
configuration. Here, provider
it is once again used to inject repositories into view models.
Take a look at how the GoRouter
is set up to inject dependencies into each screen:
// router.dart (simplified for demo)
GoRouter router(AuthRepository authRepository) => GoRouter(
initialLocation: Routes.home,
debugLogDiagnostics: true,
redirect: _redirect,
refreshListenable: authRepository,
routes: [
GoRoute(
path: Routes.login,
builder: (context, state) {
return LoginScreen(
viewModel: LoginViewModel(
authRepository: context.read(),
),
);
},
),
GoRoute(
path: Routes.home,
builder: (context, state) {
final viewModel = HomeViewModel(
bookingRepository: context.read(),
);
return HomeScreen(viewModel: viewModel);
},
),
],
);
Why This Approach Works Well
Keeps ViewModel creation close to the screen that needs it.
Maintains constructor-based dependency injection, ideal for testing.
Avoids global state by leveraging context-scoped objects.
Keeps logic out of widgets and focuses on composition.
By using go_router
and provider
Together, you achieve clean navigation while maintaining modular architecture. Each screen owns its ViewModel, and each ViewModel receives its dependencies from the surrounding widget tree.
Encapsulation of Injected Dependencies
Encapsulation of Injected Dependencies
Encapsulation of Injected Dependencies
Encapsulation of Injected Dependencies
Once a component like a Repository
is injected into a ViewModel or another layer, it should not be exposed publicly. Instead, the dependency should be stored in a private field. This ensures that only the internal logic of that class interacts with the dependency, preserving encapsulation and protecting the integrity of your architecture.
Here’s a practical example of how this looks in the HomeViewModel
class:
// home_viewmodel.dart
class HomeViewModel extends ChangeNotifier {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
// Example usage:
Future<void> fetchBookings() async {
final bookings = await _bookingRepository.getBookings();
// handle bookings...
notifyListeners();
}
}
Why Use Private Fields?
Encapsulation: Internal logic can evolve without affecting external components.
Maintainability: Prevents misuse of services or repositories outside of intended use.
Testability: Easier to mock and verify usage within a confined scope.
Consistency: Reinforces a clean architecture where dependencies stay within their layer.
This pattern applies equally to repositories consuming services or services consuming clients; each should privately store the injected object and expose only what is necessary.
Once a component like a Repository
is injected into a ViewModel or another layer, it should not be exposed publicly. Instead, the dependency should be stored in a private field. This ensures that only the internal logic of that class interacts with the dependency, preserving encapsulation and protecting the integrity of your architecture.
Here’s a practical example of how this looks in the HomeViewModel
class:
// home_viewmodel.dart
class HomeViewModel extends ChangeNotifier {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
// Example usage:
Future<void> fetchBookings() async {
final bookings = await _bookingRepository.getBookings();
// handle bookings...
notifyListeners();
}
}
Why Use Private Fields?
Encapsulation: Internal logic can evolve without affecting external components.
Maintainability: Prevents misuse of services or repositories outside of intended use.
Testability: Easier to mock and verify usage within a confined scope.
Consistency: Reinforces a clean architecture where dependencies stay within their layer.
This pattern applies equally to repositories consuming services or services consuming clients; each should privately store the injected object and expose only what is necessary.
Once a component like a Repository
is injected into a ViewModel or another layer, it should not be exposed publicly. Instead, the dependency should be stored in a private field. This ensures that only the internal logic of that class interacts with the dependency, preserving encapsulation and protecting the integrity of your architecture.
Here’s a practical example of how this looks in the HomeViewModel
class:
// home_viewmodel.dart
class HomeViewModel extends ChangeNotifier {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
// Example usage:
Future<void> fetchBookings() async {
final bookings = await _bookingRepository.getBookings();
// handle bookings...
notifyListeners();
}
}
Why Use Private Fields?
Encapsulation: Internal logic can evolve without affecting external components.
Maintainability: Prevents misuse of services or repositories outside of intended use.
Testability: Easier to mock and verify usage within a confined scope.
Consistency: Reinforces a clean architecture where dependencies stay within their layer.
This pattern applies equally to repositories consuming services or services consuming clients; each should privately store the injected object and expose only what is necessary.
Once a component like a Repository
is injected into a ViewModel or another layer, it should not be exposed publicly. Instead, the dependency should be stored in a private field. This ensures that only the internal logic of that class interacts with the dependency, preserving encapsulation and protecting the integrity of your architecture.
Here’s a practical example of how this looks in the HomeViewModel
class:
// home_viewmodel.dart
class HomeViewModel extends ChangeNotifier {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
// Example usage:
Future<void> fetchBookings() async {
final bookings = await _bookingRepository.getBookings();
// handle bookings...
notifyListeners();
}
}
Why Use Private Fields?
Encapsulation: Internal logic can evolve without affecting external components.
Maintainability: Prevents misuse of services or repositories outside of intended use.
Testability: Easier to mock and verify usage within a confined scope.
Consistency: Reinforces a clean architecture where dependencies stay within their layer.
This pattern applies equally to repositories consuming services or services consuming clients; each should privately store the injected object and expose only what is necessary.
Wrapping Up
Wrapping Up
Wrapping Up
Wrapping Up
Using private fields and methods within your view models ensures that only the intended logic interacts with your repositories. This prevents views—which have access to the view model—from calling repository methods directly, preserving encapsulation and separation of concerns.
Final Thoughts on Layer Communication
With that, we conclude the code walkthrough inspired by the Compass app. While we focused specifically on the architecture and dependency injection layers, it’s important to note that this is just one part of a fully functioning app.
The Compass app also contains:
Utility methods
Reusable widgets
Theming and UI styling
Navigation structure and more
For a complete picture of how a robust Flutter application is architected, we recommend exploring the Compass app repository.
🚀 Want to Build Cleaner Flutter Apps?
Apply these architectural patterns in your next Flutter project and see how they improve maintainability and testability. Up next in this series: Testing Each Architecture Layer in Flutter.
Let me know if you'd like a summary checklist, downloadable architecture diagram, or next blog draft!
Using private fields and methods within your view models ensures that only the intended logic interacts with your repositories. This prevents views—which have access to the view model—from calling repository methods directly, preserving encapsulation and separation of concerns.
Final Thoughts on Layer Communication
With that, we conclude the code walkthrough inspired by the Compass app. While we focused specifically on the architecture and dependency injection layers, it’s important to note that this is just one part of a fully functioning app.
The Compass app also contains:
Utility methods
Reusable widgets
Theming and UI styling
Navigation structure and more
For a complete picture of how a robust Flutter application is architected, we recommend exploring the Compass app repository.
🚀 Want to Build Cleaner Flutter Apps?
Apply these architectural patterns in your next Flutter project and see how they improve maintainability and testability. Up next in this series: Testing Each Architecture Layer in Flutter.
Let me know if you'd like a summary checklist, downloadable architecture diagram, or next blog draft!
Using private fields and methods within your view models ensures that only the intended logic interacts with your repositories. This prevents views—which have access to the view model—from calling repository methods directly, preserving encapsulation and separation of concerns.
Final Thoughts on Layer Communication
With that, we conclude the code walkthrough inspired by the Compass app. While we focused specifically on the architecture and dependency injection layers, it’s important to note that this is just one part of a fully functioning app.
The Compass app also contains:
Utility methods
Reusable widgets
Theming and UI styling
Navigation structure and more
For a complete picture of how a robust Flutter application is architected, we recommend exploring the Compass app repository.
🚀 Want to Build Cleaner Flutter Apps?
Apply these architectural patterns in your next Flutter project and see how they improve maintainability and testability. Up next in this series: Testing Each Architecture Layer in Flutter.
Let me know if you'd like a summary checklist, downloadable architecture diagram, or next blog draft!
Using private fields and methods within your view models ensures that only the intended logic interacts with your repositories. This prevents views—which have access to the view model—from calling repository methods directly, preserving encapsulation and separation of concerns.
Final Thoughts on Layer Communication
With that, we conclude the code walkthrough inspired by the Compass app. While we focused specifically on the architecture and dependency injection layers, it’s important to note that this is just one part of a fully functioning app.
The Compass app also contains:
Utility methods
Reusable widgets
Theming and UI styling
Navigation structure and more
For a complete picture of how a robust Flutter application is architected, we recommend exploring the Compass app repository.
🚀 Want to Build Cleaner Flutter Apps?
Apply these architectural patterns in your next Flutter project and see how they improve maintainability and testability. Up next in this series: Testing Each Architecture Layer in Flutter.
Let me know if you'd like a summary checklist, downloadable architecture diagram, or next blog draft!
Table of content
© 2021-25 Blupx Private Limited.
All rights reserved.
© 2021-25 Blupx Private Limited.
All rights reserved.
© 2021-25 Blupx Private Limited.
All rights reserved.