Welcome to Part 5 of the Flutter Interview Questions series! This part focuses on the architectural foundations that separate hobby projects from production-grade Flutter applications. We cover Clean Architecture, MVVM, the Repository pattern, Dependency Injection with get_it and injectable, SOLID principles with Flutter-specific examples, and classic Design Patterns such as Singleton, Factory, Observer, Builder, Strategy, Decorator, Adapter, and Command -- all applied in a Flutter context. This is part 5 of a 14-part series, so bookmark it and come back as you level up your preparation.
What's in this part?
- Clean Architecture -- layers, folder structure, Use Cases, Entity vs Model, error handling, caching, testing
- MVVM -- Model-View-ViewModel in Flutter, data binding, navigation, state sharing
- Repository Pattern -- offline-first, pagination, Stream vs Future, DataSource vs Repository
- Dependency Injection -- get_it, injectable, environment-specific DI, scopes
- SOLID Principles -- each principle with Flutter code examples
- Design Patterns -- Singleton, Factory, Observer, Builder, Strategy, Decorator, Adapter, Command
PART A: ARCHITECTURE
1. Clean Architecture in Flutter
Q1: What is Clean Architecture and why should we use it in Flutter?
Answer:
Clean Architecture, introduced by Robert C. Martin (Uncle Bob), is a software design philosophy that separates code into concentric layers with strict dependency rules. In Flutter, it typically has three layers:
- Presentation Layer -- Widgets, BLoC/Cubit/ViewModel, UI logic
- Domain Layer -- Entities, Use Cases, Repository interfaces (abstract classes). This is the innermost layer with ZERO dependencies on Flutter or any package.
- Data Layer -- Repository implementations, Data Sources (remote/local), Models (DTOs)
Why use it:
- Testability -- Each layer can be tested independently. Domain layer has no framework dependency.
- Scalability -- Adding features does not break existing ones.
- Maintainability -- Clear separation makes onboarding new developers easier.
- Independence -- Domain logic does not depend on UI framework, database, or network library. You could swap Dio for http, or Hive for SharedPreferences, without touching business logic.
The key rule is the Dependency Rule: source code dependencies can only point inward. Data layer depends on Domain, Presentation depends on Domain, but Domain depends on nothing external.
Q2: Explain the folder structure you would use for Clean Architecture in Flutter.
Answer:
lib/
├── core/
│ ├── error/ (failures, exceptions)
│ ├── usecases/ (base UseCase abstract class)
│ ├── network/ (network info, API client)
│ └── utils/ (constants, extensions)
├── features/
│ └── authentication/
│ ├── data/
│ │ ├── datasources/
│ │ │ ├── auth_remote_data_source.dart
│ │ │ └── auth_local_data_source.dart
│ │ ├── models/
│ │ │ └── user_model.dart
│ │ └── repositories/
│ │ └── auth_repository_impl.dart
│ ├── domain/
│ │ ├── entities/
│ │ │ └── user.dart
│ │ ├── repositories/
│ │ │ └── auth_repository.dart (abstract)
│ │ └── usecases/
│ │ ├── login.dart
│ │ └── register.dart
│ └── presentation/
│ ├── bloc/
│ │ ├── auth_bloc.dart
│ │ ├── auth_event.dart
│ │ └── auth_state.dart
│ ├── pages/
│ │ └── login_page.dart
│ └── widgets/
│ └── login_form.dart
└── injection_container.dart
Each feature is self-contained. The domain layer defines abstract repository contracts. The data layer fulfills those contracts. The presentation layer calls use cases from the domain layer.
Q3: What is a Use Case in Clean Architecture? Give an example.
Answer:
A Use Case (also called an Interactor) encapsulates a single piece of business logic. It is a class in the Domain layer that takes input, calls the repository, and returns output. Each Use Case does exactly one thing.
// Base class
abstract class UseCase<Type, Params> {
Future<Either<Failure, Type>> call(Params params);
}
// Concrete use case
class GetUserProfile extends UseCase<User, String> {
final UserRepository repository;
GetUserProfile(this.repository);
@override
Future<Either<Failure, User>> call(String userId) {
return repository.getUserProfile(userId);
}
}
// No-params use case
class GetAllProducts extends UseCase<List<Product>, NoParams> {
final ProductRepository repository;
GetAllProducts(this.repository);
@override
Future<Either<Failure, List<Product>>> call(NoParams params) {
return repository.getAllProducts();
}
}
We use Either<Failure, Type> from the dartz or fpdart package for functional error handling -- Left for failure, Right for success. This avoids try-catch scattered across layers.
Q4: What is the difference between Entity and Model in Clean Architecture?
Answer:
- Entity (Domain Layer): A pure Dart class representing the core business object. It contains only fields and possibly business logic methods. It has no dependency on serialization, database, or API.
class User {
final String id;
final String name;
final String email;
const User({required this.id, required this.name, required this.email});
}
- Model (Data Layer): Extends or maps to the Entity. It adds serialization/deserialization logic (fromJson, toJson, fromEntity, toEntity). It is specific to the data source.
class UserModel extends User {
const UserModel({required super.id, required super.name, required super.email});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
Map<String, dynamic> toJson() => {'id': id, 'name': name, 'email': email};
}
This separation ensures that if the API response changes, only the Model changes -- the Entity and all business logic remain untouched.
Q5: How does error handling work in Clean Architecture?
Answer:
We define custom Failure classes in the Domain layer and Exception classes in the Data layer:
// Domain layer - abstract failures
abstract class Failure extends Equatable {
final String message;
const Failure(this.message);
@override
List<Object> get props => [message];
}
class ServerFailure extends Failure {
const ServerFailure(super.message);
}
class CacheFailure extends Failure {
const CacheFailure(super.message);
}
class NetworkFailure extends Failure {
const NetworkFailure(super.message);
}
In the Data layer, the Repository implementation catches exceptions and maps them to Failures:
class UserRepositoryImpl implements UserRepository {
final RemoteDataSource remoteDataSource;
final NetworkInfo networkInfo;
@override
Future<Either<Failure, User>> getUser(String id) async {
if (await networkInfo.isConnected) {
try {
final result = await remoteDataSource.getUser(id);
return Right(result);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
}
} else {
return Left(NetworkFailure('No internet connection'));
}
}
}
The Presentation layer (BLoC/Cubit) receives Either<Failure, Data> and folds it:
final result = await getUserProfile(userId);
result.fold(
(failure) => emit(UserError(failure.message)),
(user) => emit(UserLoaded(user)),
);
Q6: How do you handle caching strategy in Clean Architecture?
Answer:
The Repository implementation orchestrates the caching strategy by coordinating between remote and local data sources:
class ArticleRepositoryImpl implements ArticleRepository {
final ArticleRemoteDataSource remote;
final ArticleLocalDataSource local;
final NetworkInfo networkInfo;
@override
Future<Either<Failure, List<Article>>> getArticles() async {
if (await networkInfo.isConnected) {
try {
final remoteArticles = await remote.getArticles();
await local.cacheArticles(remoteArticles); // Save to cache
return Right(remoteArticles);
} on ServerException {
return Left(ServerFailure('Server error'));
}
} else {
try {
final cachedArticles = await local.getCachedArticles();
return Right(cachedArticles);
} on CacheException {
return Left(CacheFailure('No cached data'));
}
}
}
}
Common strategies: Cache-first (read cache, fallback to network), Network-first (fetch remote, fallback to cache), Stale-while-revalidate (return cache immediately, update from network in background).
Q7: What are the drawbacks of Clean Architecture in Flutter?
Answer:
- Boilerplate -- Even a simple feature requires creating Entity, Model, DataSource, Repository interface, Repository implementation, UseCase, and BLoC classes.
- Over-engineering for small apps -- For a simple CRUD app, Clean Architecture adds unnecessary complexity.
- Learning curve -- Junior developers may struggle understanding all the layers and their interactions.
- Indirection overhead -- Data flows through many layers, which can make debugging harder.
- Feature coupling -- When features share entities or use cases, cross-feature dependencies become tricky.
Mitigations: Use code generators (freezed, json_serializable, injectable), consider a simplified 2-layer architecture for small apps, and only introduce Use Cases when business logic exists beyond simple CRUD.
Q8: How would you test each layer of Clean Architecture?
Answer:
- Domain Layer (Unit Tests): Test Use Cases by mocking the Repository interface.
test('should get user from repository', () async {
when(mockRepository.getUser('1')).thenAnswer((_) async => Right(testUser));
final result = await getUser('1');
expect(result, Right(testUser));
verify(mockRepository.getUser('1'));
});
Data Layer (Unit Tests): Test Repository implementations by mocking DataSources and NetworkInfo. Test Models for JSON serialization/deserialization. Test DataSources by mocking HTTP client.
Presentation Layer (Unit + Widget Tests): Test BLoC by verifying state emissions using
bloc_test. Widget test the UI by providing a mocked BLoC.
blocTest<UserBloc, UserState>(
'emits [Loading, Loaded] when GetUser is successful',
build: () {
when(mockGetUser(any)).thenAnswer((_) async => Right(testUser));
return UserBloc(getUser: mockGetUser);
},
act: (bloc) => bloc.add(FetchUser('1')),
expect: () => [UserLoading(), UserLoaded(testUser)],
);
2. MVVM Pattern
Q1: What is MVVM and how does it differ from MVC and MVP in Flutter?
Answer:
MVVM (Model-View-ViewModel):
- Model -- Data and business logic (API calls, database, data classes)
- View -- Flutter Widgets (UI only, no logic)
- ViewModel -- Holds UI state, transforms data from Model for the View. The View observes the ViewModel reactively.
Key differences:
| Aspect | MVC | MVP | MVVM |
|---|---|---|---|
| Communication | Controller mediates | Presenter calls View interface | ViewModel exposes observable state |
| View coupling | View knows Controller | View implements interface | View observes ViewModel |
| Testability | Hard (Controller depends on View) | Medium (mock View interface) | Excellent (ViewModel is pure Dart) |
| Flutter fit | Poor | Moderate | Excellent |
MVVM is the best fit for Flutter because Flutter is inherently reactive -- widgets rebuild when state changes. The ViewModel can be implemented using ChangeNotifier, StateNotifier, Riverpod's Notifier, or BLoC/Cubit.
Q2: How do you implement MVVM in Flutter with a practical example?
Answer:
// Model
class Product {
final String id, name;
final double price;
const Product({required this.id, required this.name, required this.price});
}
// Service / Repository
class ProductRepository {
final ApiClient api;
ProductRepository(this.api);
Future<List<Product>> fetchProducts() => api.get('/products');
}
// ViewModel
class ProductViewModel extends ChangeNotifier {
final ProductRepository _repository;
List<Product> _products = [];
List<Product> get products => _products;
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _error;
String? get error => _error;
ProductViewModel(this._repository);
Future<void> loadProducts() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_products = await _repository.fetchProducts();
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
}
// View
class ProductPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (ctx) => GetIt.I<ProductViewModel>()..loadProducts(),
child: Consumer<ProductViewModel>(
builder: (ctx, vm, _) {
if (vm.isLoading) return CircularProgressIndicator();
if (vm.error != null) return Text(vm.error!);
return ListView.builder(
itemCount: vm.products.length,
itemBuilder: (ctx, i) => ListTile(title: Text(vm.products[i].name)),
);
},
),
);
}
}
The View only calls ViewModel methods and reads ViewModel properties. It has zero knowledge of the repository or API.
Q3: How does data binding work in Flutter MVVM?
Answer:
Flutter does not have native two-way data binding like Angular. Instead, we achieve reactive binding through:
ChangeNotifier + Consumer/Selector (Provider) -- ViewModel extends ChangeNotifier, calls notifyListeners(). Consumer widget rebuilds on change.
StateNotifier + ref.watch (Riverpod) -- ViewModel extends StateNotifier, View uses ref.watch to reactively read state.
BLoC/Cubit + BlocBuilder -- Cubit emits states, BlocBuilder rebuilds on state change.
ValueNotifier + ValueListenableBuilder -- Lightweight alternative for simple cases.
For two-way binding (e.g., form fields), we use TextEditingController in the View and sync with ViewModel:
// In ViewModel
String _email = '';
void updateEmail(String val) { _email = val; notifyListeners(); }
// In View
TextField(
onChanged: (val) => vm.updateEmail(val),
)
Riverpod's ref.watch is the closest Flutter gets to true reactive binding -- the widget automatically disposes the subscription when unmounted.
Q4: What are the advantages of MVVM over BLoC pattern?
Answer:
This is a nuanced question because BLoC can be seen as an implementation of MVVM:
MVVM advantages (using ChangeNotifier/Riverpod):
- Less boilerplate than BLoC (no separate Event classes)
- Simpler learning curve
- Direct method calls instead of event dispatching
- Cubit is essentially MVVM with BLoC library tooling
BLoC advantages:
- Enforces unidirectional data flow via Events
- Better traceability (every state change is triggered by a named Event)
- Built-in event transformers (debounce, throttle, sequential)
- Better for complex features with many state transitions
In practice: Many teams use Cubit (which is method-call-based like MVVM) for simple features and full BLoC (with Events) for complex features. Riverpod Notifier is another popular MVVM implementation that provides better dependency management.
Q5: How do you handle navigation in MVVM?
Answer:
Navigation should not happen inside the ViewModel because it depends on BuildContext. Common approaches:
1. Callback approach:
class LoginViewModel extends ChangeNotifier {
VoidCallback? onLoginSuccess;
Future<void> login() async {
// ... login logic
if (success) onLoginSuccess?.call();
}
}
// In View
vm.onLoginSuccess = () => Navigator.pushNamed(context, '/home');
2. State-driven navigation:
// ViewModel emits a navigation state
sealed class LoginState {}
class LoginInitial extends LoginState {}
class LoginSuccess extends LoginState { final User user; }
class LoginError extends LoginState { final String message; }
// View listens and navigates
BlocListener<LoginCubit, LoginState>(
listener: (context, state) {
if (state is LoginSuccess) Navigator.pushReplacementNamed(context, '/home');
},
)
3. Navigator 2.0 / GoRouter with state:
The router reads app state (e.g., authState) and declaratively determines the route stack. The ViewModel just changes the auth state, and GoRouter's redirect logic handles navigation.
State-driven navigation (option 2 or 3) is preferred in production apps because it is testable and predictable.
Q6: How do you share state between multiple ViewModels?
Answer:
Several approaches:
1. Shared service/repository: Both ViewModels depend on the same service. When data changes in the service, both can be notified.
2. Stream-based communication:
class CartService {
final _cartStream = StreamController<List<CartItem>>.broadcast();
Stream<List<CartItem>> get cartStream => _cartStream.stream;
void addItem(CartItem item) {
_items.add(item);
_cartStream.add(_items);
}
}
// Both ProductViewModel and CartViewModel listen to cartStream
3. Riverpod ref.watch: One provider watches another. When the source changes, the dependent rebuilds automatically.
final cartProvider = StateNotifierProvider<CartNotifier, CartState>(...);
final checkoutProvider = Provider((ref) {
final cart = ref.watch(cartProvider); // auto-rebuilds
return CheckoutViewModel(cart);
});
4. Event bus (not recommended): A global event bus. This creates hidden dependencies and makes debugging difficult. Prefer explicit dependency injection.
3. Repository Pattern
Q1: What is the Repository Pattern and why is it important in Flutter?
Answer:
The Repository Pattern is an abstraction layer between the domain/business logic and data sources. The repository provides a clean API for data access, hiding the details of whether data comes from a REST API, local database, cache, or any other source.
// Abstract repository (Domain layer)
abstract class UserRepository {
Future<Either<Failure, User>> getUser(String id);
Future<Either<Failure, void>> updateUser(User user);
}
// Implementation (Data layer)
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource remote;
final UserLocalDataSource local;
final NetworkInfo networkInfo;
UserRepositoryImpl({required this.remote, required this.local, required this.networkInfo});
@override
Future<Either<Failure, User>> getUser(String id) async {
// Decide whether to fetch from remote or local
}
}
Why it matters:
- Separation of concerns -- Business logic does not know about HTTP, SQL, or SharedPreferences.
- Testability -- Mock the repository interface to unit test Use Cases/ViewModels.
- Flexibility -- Swap data sources without changing business logic.
- Single source of truth -- Repository decides the caching/fetching strategy.
Q2: How do you implement offline-first with the Repository Pattern?
Answer:
class ArticleRepositoryImpl implements ArticleRepository {
final ArticleRemoteDataSource remote;
final ArticleLocalDataSource local;
final NetworkInfo networkInfo;
@override
Stream<Either<Failure, List<Article>>> watchArticles() async* {
// Step 1: Immediately emit cached data
try {
final cached = await local.getArticles();
if (cached.isNotEmpty) yield Right(cached);
} catch (_) {}
// Step 2: If online, fetch fresh data
if (await networkInfo.isConnected) {
try {
final fresh = await remote.getArticles();
await local.saveArticles(fresh); // Update cache
yield Right(fresh);
} on ServerException catch (e) {
yield Left(ServerFailure(e.message));
}
}
}
}
This gives the user instant data from cache, then silently refreshes from the network. The pattern is called stale-while-revalidate. For write operations, you can implement a sync queue that stores mutations locally and syncs them when connectivity is restored.
Q3: Should a Repository return a Stream or a Future?
Answer:
- Future -- For one-shot operations (login, submit form, fetch single resource). Most common.
- Stream -- For reactive/real-time data (chat messages, database watch, location updates, the stale-while-revalidate pattern above).
In practice, many repositories use both:
abstract class ChatRepository {
Future<Either<Failure, void>> sendMessage(Message msg); // One-shot
Stream<List<Message>> watchMessages(String chatId); // Reactive
Future<Either<Failure, List<Message>>> getHistory(String id); // One-shot
}
With Riverpod, you can use StreamProvider or FutureProvider depending on the return type.
Q4: What is the difference between DataSource and Repository?
Answer:
-
DataSource -- Talks directly to one specific data source. It knows about HTTP, SQL, SharedPreferences, Firebase, etc. It throws Exceptions.
-
RemoteDataSource-- Makes API calls, throwsServerException. -
LocalDataSource-- Reads/writes local DB, throwsCacheException.
-
Repository -- Orchestrates multiple DataSources, implements caching strategy, handles network checks, catches Exceptions, and returns Failures wrapped in
Either.
UseCase --> Repository (abstract) --> RepositoryImpl --> DataSource(s)
The Repository never exposes raw exceptions to the domain layer. It translates them into domain-specific Failures.
Q5: How do you handle pagination in the Repository Pattern?
Answer:
abstract class ProductRepository {
Future<Either<Failure, PaginatedResult<Product>>> getProducts({
required int page,
required int pageSize,
String? searchQuery,
});
}
class PaginatedResult<T> {
final List<T> items;
final int totalCount;
final bool hasMore;
final int currentPage;
const PaginatedResult({
required this.items,
required this.totalCount,
required this.hasMore,
required this.currentPage,
});
}
class ProductRepositoryImpl implements ProductRepository {
@override
Future<Either<Failure, PaginatedResult<Product>>> getProducts({
required int page,
required int pageSize,
String? searchQuery,
}) async {
try {
final response = await remote.getProducts(page: page, pageSize: pageSize);
if (page == 1) {
await local.clearAndCacheProducts(response.items);
} else {
await local.appendCachedProducts(response.items);
}
return Right(response);
} on ServerException catch (e) {
if (page == 1) {
// Only fallback to cache for the first page
try {
final cached = await local.getCachedProducts();
return Right(PaginatedResult(items: cached, totalCount: cached.length, hasMore: false, currentPage: 1));
} catch (_) {}
}
return Left(ServerFailure(e.message));
}
}
}
4. Dependency Injection -- get_it & injectable
Q1: What is Dependency Injection and why is it critical in Flutter?
Answer:
Dependency Injection (DI) is a design pattern where objects receive their dependencies from an external source rather than creating them internally. Instead of a class instantiating its own dependencies with final repo = UserRepositoryImpl(), the dependency is passed in via the constructor.
Why it is critical:
- Testability -- Inject mock implementations during tests.
- Loose coupling -- Classes depend on abstractions (interfaces), not concrete implementations.
- Configurability -- Swap real services with fakes for different environments (dev, staging, prod).
- Lifecycle management -- DI containers manage singleton vs. factory lifetimes.
Without DI:
class UserBloc {
final repo = UserRepositoryImpl(ApiClient(), Database()); // tightly coupled
}
With DI:
class UserBloc {
final UserRepository repo; // depends on abstraction
UserBloc(this.repo);
}
Q2: How does get_it work and what are the different registration types?
Answer:
get_it is a simple Service Locator for Dart/Flutter. It maintains a map of type registrations.
final sl = GetIt.instance; // sl = service locator
void setupDependencies() {
// 1. Factory - new instance every time
sl.registerFactory<LoginBloc>(() => LoginBloc(sl()));
// 2. Singleton - created immediately, same instance forever
sl.registerSingleton<ApiClient>(ApiClient());
// 3. LazySingleton - created on first access, same instance after
sl.registerLazySingleton<UserRepository>(
() => UserRepositoryImpl(sl(), sl()),
);
// 4. FactoryWithParam - factory with parameters
sl.registerFactoryParam<UserDetailBloc, String, void>(
(userId, _) => UserDetailBloc(userId: userId, repo: sl()),
);
}
// Usage
final bloc = sl<LoginBloc>(); // get_it resolves all nested dependencies
Registration types comparison:
| Type | When Created | Instance |
|---|---|---|
registerFactory |
Every call | New each time |
registerSingleton |
At registration | Always same |
registerLazySingleton |
First call | Always same |
registerFactoryParam |
Every call (with params) | New each time |
Async registration for dependencies that need initialization:
sl.registerSingletonAsync<Database>(() async {
final db = Database();
await db.initialize();
return db;
});
await sl.allReady(); // Wait for all async singletons
Q3: What is Injectable and how does it work with get_it?
Answer:
injectable is a code generator that automatically generates get_it registration code using annotations. It eliminates manual DI setup.
// Step 1: Annotate classes
@injectable
class UserRepositoryImpl implements UserRepository {
final ApiClient api;
final Database db;
UserRepositoryImpl(this.api, this.db);
}
@lazySingleton
class ApiClient {
ApiClient(@Named('baseUrl') this.baseUrl);
final String baseUrl;
}
@module
abstract class RegisterModule {
@Named('baseUrl')
String get baseUrl => 'https://api.example.com';
@preResolve // for async dependencies
Future<SharedPreferences> get prefs => SharedPreferences.getInstance();
}
// Step 2: Setup
@InjectableInit()
Future<void> configureDependencies() async => await getIt.init();
Key annotations:
-
@injectable-- Register as factory -
@singleton-- Register as singleton -
@lazySingleton-- Register as lazy singleton -
@module-- Register third-party classes you cannot annotate -
@preResolve-- For async initialization -
@Named('key')-- Distinguish between multiple implementations of same type -
@Environment('dev')/@Environment('prod')-- Environment-specific registration -
@Order(1)-- Control registration order
Q4: How do you handle environment-specific DI (dev, staging, prod)?
Answer:
// Define implementations
@LazySingleton(as: ApiClient, env: [Environment.prod])
class ProdApiClient implements ApiClient {
@override
String get baseUrl => 'https://api.production.com';
}
@LazySingleton(as: ApiClient, env: [Environment.dev])
class DevApiClient implements ApiClient {
@override
String get baseUrl => 'https://api.dev.com';
}
// Initialize with environment
@InjectableInit()
Future<void> configureDependencies(String env) async => await getIt.init(environment: env);
// In main.dart
void main() async {
await configureDependencies(
kReleaseMode ? Environment.prod : Environment.dev,
);
runApp(MyApp());
}
For testing:
@InjectableInit(asExtension: true)
void configureDependencies() => getIt.init(environment: Environment.test);
Q5: get_it vs Provider vs Riverpod for Dependency Injection -- when to use which?
Answer:
| Feature | get_it | Provider | Riverpod |
|---------|--------|----------|----------|
| Type | Service Locator | DI + State Mgmt | DI + State Mgmt |
| BuildContext needed | No | Yes | No (with ref) |
| Scoping | Global | Widget tree scoped | Auto-scoped |
| Compile-time safety | No (runtime errors) | No | Yes |
| Testability | Good (register mocks) | Good (override in tests) | Excellent (ProviderScope overrides) |
| Use with Clean Arch | Excellent | Good | Excellent |
When to use:
- get_it -- When you want DI independent of any state management. Great for Clean Architecture. Use it to inject repositories and data sources, and use a separate tool (BLoC, Riverpod) for state management.
- Provider -- When you want simple DI + state management in one package. Good for small-to-medium apps.
- Riverpod -- When you want robust DI + state management, compile-time safety, no BuildContext dependency, and auto-disposal. Best for medium-to-large apps.
Many projects combine get_it (for data/domain layer DI) with BLoC or Riverpod (for presentation layer state management).
Q6: How do you unregister or reset dependencies in get_it?
Answer:
// Unregister a specific type
GetIt.I.unregister<UserRepository>();
// Reset all registrations (useful in tests)
await GetIt.I.reset();
// Register with dispose function
GetIt.I.registerLazySingleton<Database>(
() => Database(),
dispose: (db) => db.close(), // Called on unregister/reset
);
// Override for testing (if allowReassignment is true)
GetIt.I.allowReassignment = true;
GetIt.I.registerSingleton<UserRepository>(MockUserRepository());
// Scopes (push/pop)
GetIt.I.pushNewScope(scopeName: 'userSession');
GetIt.I.registerSingleton<UserSession>(UserSession(token: token));
// Later...
await GetIt.I.popScope(); // Disposes all registrations in this scope
Scopes are useful for user-session-level dependencies that should be disposed on logout.
5. SOLID Principles in Flutter
Q1: Explain each SOLID principle with Flutter examples.
Answer:
S -- Single Responsibility Principle (SRP):
A class should have only one reason to change.
// BAD: Widget fetches data, parses JSON, and renders UI
class UserPage extends StatefulWidget { ... }
// GOOD: Separated concerns
class UserRepository { ... } // Data fetching
class UserModel { ... } // Data parsing
class UserCubit { ... } // State management
class UserPage extends StatelessWidget { ... } // UI only
O -- Open/Closed Principle (OCP):
Classes should be open for extension, closed for modification.
// BAD: Adding a new payment method requires modifying existing code
void processPayment(String type) {
if (type == 'card') { ... }
else if (type == 'paypal') { ... } // Modify this every time
}
// GOOD: Use abstraction
abstract class PaymentStrategy {
Future<void> pay(double amount);
}
class CardPayment implements PaymentStrategy { ... }
class PayPalPayment implements PaymentStrategy { ... }
class CryptoPayment implements PaymentStrategy { ... } // Just add new class
L -- Liskov Substitution Principle (LSP):
Subtypes must be substitutable for their base types without breaking behavior.
// BAD: Square overrides Rectangle behavior unexpectedly
class Rectangle { void setWidth(int w); void setHeight(int h); }
class Square extends Rectangle { // Setting width also sets height -- breaks expectations }
// GOOD in Flutter: Any Widget implementing PreferredSizeWidget
// can be used as AppBar's bottom property
AppBar(
bottom: myCustomPreferredSizeWidget, // Any implementation works
)
I -- Interface Segregation Principle (ISP):
Clients should not depend on interfaces they do not use.
// BAD: One massive repository
abstract class Repository {
Future<User> getUser();
Future<void> saveUser(User user);
Future<List<Product>> getProducts();
Future<void> saveProduct(Product p);
}
// GOOD: Segregated interfaces
abstract class UserRepository {
Future<User> getUser();
Future<void> saveUser(User user);
}
abstract class ProductRepository {
Future<List<Product>> getProducts();
Future<void> saveProduct(Product p);
}
D -- Dependency Inversion Principle (DIP):
High-level modules should not depend on low-level modules. Both should depend on abstractions.
// BAD: BLoC depends directly on concrete implementation
class UserBloc {
final UserRepositoryImpl repo = UserRepositoryImpl(); // Concrete dependency
}
// GOOD: BLoC depends on abstraction
class UserBloc {
final UserRepository repo; // Abstract dependency
UserBloc(this.repo); // Injected
}
Q2: How does the Dependency Inversion Principle enable testability?
Answer:
When your BLoC/ViewModel depends on an abstract UserRepository instead of UserRepositoryImpl, you can inject a mock in tests:
class MockUserRepository extends Mock implements UserRepository {}
void main() {
late UserBloc bloc;
late MockUserRepository mockRepo;
setUp(() {
mockRepo = MockUserRepository();
bloc = UserBloc(mockRepo); // Inject mock
});
test('emits loaded state on success', () {
when(mockRepo.getUser('1')).thenAnswer((_) async => Right(testUser));
bloc.add(FetchUser('1'));
expectLater(bloc.stream, emits(UserLoaded(testUser)));
});
}
Without DIP, you would be making real API calls in tests, which is slow, flaky, and impossible to control.
Q3: How do you apply SRP to a Flutter widget that has become too large?
Answer:
Break it into:
- Smaller widgets -- Extract visual components (AppBar, body sections, list items) into separate widget classes.
- State management class -- Move all state logic into BLoC/Cubit/ViewModel.
- Utility classes -- Extract formatting, validation, and helper functions.
- Mixins -- Extract reusable behavior (e.g., form validation mixin).
// Before: 500-line StatefulWidget with API calls, form validation, navigation
// After:
class LoginPage extends StatelessWidget { // ~50 lines: layout only
Widget build(ctx) => BlocProvider(
create: (_) => sl<LoginCubit>(),
child: LoginForm(),
);
}
class LoginForm extends StatelessWidget { ... } // ~80 lines: form UI
class LoginCubit extends Cubit<LoginState> { ... } // ~60 lines: state logic
class LoginValidator { ... } // ~30 lines: validation rules
PART B: DESIGN PATTERNS
6. Design Patterns in Flutter
Q1: How is the Singleton pattern used in Flutter? What are the pitfalls?
Answer:
A Singleton ensures only one instance of a class exists throughout the app lifecycle.
class AppConfig {
static final AppConfig _instance = AppConfig._internal();
factory AppConfig() => _instance;
AppConfig._internal();
String apiBaseUrl = '';
bool isDarkMode = false;
}
// Usage
AppConfig().apiBaseUrl = 'https://api.example.com';
print(AppConfig().apiBaseUrl); // Same instance
Where it is used in Flutter:
-
WidgetsBinding.instance-- The binding singleton - Service Locators like
GetIt.instance - Database connections, SharedPreferences wrappers
Pitfalls:
-
Hard to test -- Global state makes mocking difficult. Prefer DI with
registerSingletonover hand-written singletons. - Hidden dependencies -- Code that accesses a singleton hides its dependencies from the constructor signature.
- Thread safety -- Dart is single-threaded in the main isolate, so this is less of an issue, but singleton state accessed from multiple isolates can cause problems.
- Memory -- Lives for the entire app lifetime, never garbage collected.
Best practice: Use get_it's registerLazySingleton instead of hand-written singletons. It provides the same single-instance guarantee but with testability and dispose support.
Q2: Explain the Factory pattern in Flutter with examples.
Answer:
The Factory pattern creates objects without exposing the creation logic to the caller.
Factory constructor (Dart built-in):
abstract class SocialLogin {
factory SocialLogin(String provider) {
switch (provider) {
case 'google': return GoogleLogin();
case 'facebook': return FacebookLogin();
case 'apple': return AppleLogin();
default: throw UnimplementedError('Unknown provider: $provider');
}
}
Future<User> login();
}
class GoogleLogin implements SocialLogin {
@override
Future<User> login() async { /* Google Sign-In SDK */ }
}
Abstract Factory (for platform-specific widgets):
abstract class PlatformWidgetFactory {
Widget createButton(String text, VoidCallback onPressed);
Widget createDialog(String title, String content);
}
class MaterialWidgetFactory implements PlatformWidgetFactory {
@override
Widget createButton(String text, VoidCallback onPressed) =>
ElevatedButton(onPressed: onPressed, child: Text(text));
}
class CupertinoWidgetFactory implements PlatformWidgetFactory {
@override
Widget createButton(String text, VoidCallback onPressed) =>
CupertinoButton(onPressed: onPressed, child: Text(text));
}
Flutter itself uses the Factory pattern extensively -- ThemeData.from(), Image.network(), Image.asset() are all factory constructors.
Q3: How is the Observer pattern implemented in Flutter?
Answer:
The Observer pattern defines a one-to-many dependency where when one object changes state, all dependents are notified.
Flutter is built on this pattern at its core:
1. ChangeNotifier (built-in Observer):
class CartModel extends ChangeNotifier {
final List<Item> _items = [];
List<Item> get items => UnmodifiableListView(_items);
void add(Item item) {
_items.add(item);
notifyListeners(); // Notify all observers
}
}
2. Streams (Dart's built-in Observer):
class EventBus {
final _controller = StreamController.broadcast();
Stream get stream => _controller.stream;
void fire(event) => _controller.add(event);
void dispose() => _controller.close();
}
3. ValueNotifier:
final counter = ValueNotifier<int>(0);
// Widget observes:
ValueListenableBuilder(
valueListenable: counter,
builder: (ctx, value, _) => Text('$value'),
);
4. BLoC pattern -- The BLoC itself is an Observer (observes Events) and an Observable (emits States). Widgets observe via BlocBuilder/BlocListener.
5. Flutter's widget system -- InheritedWidget uses the observer pattern. When an InheritedWidget changes, all widgets that called dependOnInheritedWidgetOfExactType are rebuilt.
Q4: Explain the Builder pattern in Flutter.
Answer:
The Builder pattern separates the construction of a complex object from its representation.
Flutter uses this pattern extensively:
1. Widget builders (functional Builder pattern):
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ListTile(title: Text(items[index])),
);
FutureBuilder<User>(
future: fetchUser(),
builder: (context, snapshot) {
if (snapshot.hasData) return UserCard(snapshot.data!);
return CircularProgressIndicator();
},
);
BlocBuilder<UserCubit, UserState>(
builder: (context, state) => /* build widget based on state */,
);
2. Classical Builder for complex object creation:
class QueryBuilder {
String _table = '';
final List<String> _conditions = [];
int? _limit;
String? _orderBy;
QueryBuilder from(String table) { _table = table; return this; }
QueryBuilder where(String condition) { _conditions.add(condition); return this; }
QueryBuilder limit(int n) { _limit = n; return this; }
QueryBuilder orderBy(String field) { _orderBy = field; return this; }
String build() {
final buffer = StringBuffer('SELECT * FROM $_table');
if (_conditions.isNotEmpty) buffer.write(' WHERE ${_conditions.join(" AND ")}');
if (_orderBy != null) buffer.write(' ORDER BY $_orderBy');
if (_limit != null) buffer.write(' LIMIT $_limit');
return buffer.toString();
}
}
// Usage
final query = QueryBuilder()
.from('users')
.where('age > 18')
.orderBy('name')
.limit(10)
.build();
3. Theme builder: ThemeData uses a builder-like approach where you set many optional parameters to construct a complex theme object.
Q5: What is the Strategy pattern and how is it used in Flutter?
Answer:
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable at runtime.
// Strategy interface
abstract class CompressionStrategy {
List<int> compress(List<int> data);
}
class ZipCompression implements CompressionStrategy {
@override
List<int> compress(List<int> data) => /* zip logic */;
}
class GzipCompression implements CompressionStrategy {
@override
List<int> compress(List<int> data) => /* gzip logic */;
}
// Context
class FileUploader {
CompressionStrategy _strategy;
FileUploader(this._strategy);
void setStrategy(CompressionStrategy s) => _strategy = s;
Future<void> upload(List<int> data) async {
final compressed = _strategy.compress(data);
// upload compressed data
}
}
Flutter examples:
-
ScrollPhysics--BouncingScrollPhysics,ClampingScrollPhysicsare strategies. -
TextInputFormatter-- Different validation strategies for text fields. -
PageRouteBuilder-- Different transition strategies for navigation. - Sort/filter strategies in list views.
Q6: How is the Decorator pattern seen in Flutter?
Answer:
The Decorator pattern adds behavior to an object dynamically by wrapping it.
Flutter's widget composition IS the Decorator pattern:
// Each widget "decorates" the child with additional behavior
Container( // Decorates with padding, margin, color
padding: EdgeInsets.all(16),
child: Material( // Decorates with material design features
elevation: 4,
child: InkWell( // Decorates with tap ripple effect
onTap: () {},
child: Padding( // Decorates with inner padding
padding: EdgeInsets.all(8),
child: Text('Hello'), // Core widget
),
),
),
)
Every wrapper widget is essentially a decorator. Padding, Opacity, Transform, ClipRRect, DecoratedBox, SizedBox -- they all add one specific behavior to their child.
Custom decorator example:
class LoggingRepository implements UserRepository {
final UserRepository _inner; // wrapped object
final Logger _logger;
LoggingRepository(this._inner, this._logger);
@override
Future<User> getUser(String id) async {
_logger.log('Fetching user $id');
final result = await _inner.getUser(id);
_logger.log('Fetched user: ${result.name}');
return result;
}
}
Q7: Explain the Adapter pattern in Flutter.
Answer:
The Adapter pattern converts an interface into another interface that a client expects.
// Third-party analytics SDK with its own interface
class FirebaseAnalytics {
void logEvent(String name, Map<String, Object> params) { ... }
}
// Our app's analytics interface
abstract class AnalyticsService {
void trackEvent(String event, {Map<String, dynamic>? properties});
void trackScreen(String screenName);
}
// Adapter
class FirebaseAnalyticsAdapter implements AnalyticsService {
final FirebaseAnalytics _firebase;
FirebaseAnalyticsAdapter(this._firebase);
@override
void trackEvent(String event, {Map<String, dynamic>? properties}) {
_firebase.logEvent(event, properties?.cast<String, Object>() ?? {});
}
@override
void trackScreen(String screenName) {
_firebase.logEvent('screen_view', {'screen_name': screenName});
}
}
This allows you to swap analytics providers (Firebase to Mixpanel) without changing any calling code. Flutter itself uses adapters -- WidgetsFlutterBinding adapts the Flutter engine interface for widgets.
Q8: What is the Command pattern and where is it useful in Flutter?
Answer:
The Command pattern encapsulates a request as an object, allowing parameterization, queuing, and undo/redo.
BLoC Events are the Command pattern:
// Commands (Events)
sealed class TextEditorCommand {}
class InsertText extends TextEditorCommand { final String text; }
class DeleteText extends TextEditorCommand { final int start, end; }
class FormatBold extends TextEditorCommand { final int start, end; }
// Command handler with undo support
class TextEditorBloc extends Bloc<TextEditorCommand, TextEditorState> {
final List<TextEditorCommand> _undoStack = [];
final List<TextEditorCommand> _redoStack = [];
TextEditorBloc() : super(TextEditorInitial()) {
on<InsertText>((event, emit) {
_undoStack.add(event);
_redoStack.clear();
// Apply the command
});
}
}
Use cases: Undo/redo functionality, transaction queues, macro recording, delayed execution, operation logging.
Top comments (0)