Stacked Architecture in the Real World: Building Scalable Flutter Apps That Stand the Test of Time
Flutter has revolutionised mobile development with its cross-platform capabilities and beautiful UI toolkit. But as your app grows beyond a few screens, you quickly realise UI code alone isn't enough. You need structure, separation of concerns, and a clear architectural pattern. At Hoomanely, building pet healthcare technology that impacts real lives, we couldn't afford architectural debt. After evaluating multiple state management solutions, we chose Stacked, and it transformed how we build and scale our applications.
The architecture evolution in Flutter
When you start with Flutter, everything feels simple, you write widgets, manage state with setState, and your app works. As your codebase grows, cracks begin to show, business logic creeps into your widgets, state management becomes chaotic, testing becomes nearly impossible, and code reusability suffers.
MVVM and popular state management solutions
Model-View-ViewModel separates code into three layers, the model for data structures, the view for UI widgets, and the ViewModel as the intermediary handling business logic and state. The goal is keeping views dumb and logic testable.
BLoC uses streams and events to manage state, you dispatch events, BLoC processes them, and emits new states:
// BLoC Example
class UserBloc extends Bloc<UserEvent, UserState> {
UserBloc() : super(UserInitial()) {
on<LoadUser>((event, emit) async {
emit(UserLoading());
try {
final user = await userRepository.getUser(event.id);
emit(UserLoaded(user));
} catch (e) {
emit(UserError(e.toString()));
}
});
}
}It offers predictable state flow and excellent documentation, but is boilerplate-heavy with a steep learning curve. Riverpod is the evolution of Provider, offering robust dependency injection:
// Riverpod Example
final userProvider = FutureProvider.family<User, String>((ref, userId) async {
final repository = ref.watch(userRepositoryProvider);
return repository.getUser(userId);
});It has compile-time safety and flexible provider types, but a learning curve with provider types and lacks opinionated structure for large apps.

Enter Stacked
Stacked isn't just another state management library, it's a complete architectural framework built on three pillars: separation of concerns with clear boundaries between views, ViewModels, and services, testability where every component is independently testable, and scalability where architecture grows with your application.
The layers stack cleanly: Views for UI at the top, ViewModels for business logic and state, Services for shared functionality, and Data Models for entities at the bottom.
Why Stacked wins
Stacked includes a powerful navigation service out of the box, with type-safe navigation from anywhere:
// Stacked approach
_navigationService.navigateToUserDetailsView(user: user);It uses get_it under the hood for dependency injection but provides a cleaner setup that generates boilerplate for you:
@StackedApp(
routes: [
MaterialRoute(page: HomeView),
MaterialRoute(page: PetDetailsView),
],
dependencies: [
LazySingleton(classType: NavigationService),
LazySingleton(classType: AuthenticationService),
Singleton(classType: ApiService),
],
)
class App {}Stacked ViewModels are reactive by default, change a property and your UI updates automatically:
class PetListViewModel extends ReactiveViewModel {
final _petService = locator<PetService>();
List<Patient> get pets => _petService.pets;
@override
List<ListenableServiceMixin> get listenableServices => [_petService];
}Real-world implementation patterns
Each service has a single responsibility, making testing and maintenance straightforward:
// Pet management domain
class PetService extends ReactiveServiceMixin {
final _pets = ReactiveValue<List<Pet>>([]);
List<Pet> get pets => _pets.value;
Future<void> fetchPets() async {
final data = await _apiService.getPets();
_pets.value = data;
}
}Stacked ViewModels provide built-in busy state management:
class PetDetailsViewModel extends BaseViewModel {
Pet? _pet;
Pet? get pet => _pet;
Future<void> loadPet(String id) async {
runBusyFuture(_fetchPet(id));
}
Future<void> _fetchPet(String id) async {
try {
_pet = await _petService.getPet(id);
notifyListeners();
} catch (e) {
setError(e);
}
}
}In the view, ViewModelBuilder wires it together consistently, checking isBusy for loading states and hasError for error widgets before rendering content. This pattern ensures consistent loading states and error handling across all features, and Stacked's architecture makes testing natural, with mock services registered against the locator and assertions run directly against the ViewModel.
Performance and scaling
Stacked apps are performant by default through lazy service registration, initialising only when first accessed, selective rebuilds where ViewModels notify listeners only when necessary, reactive services that propagate updates efficiently, and proper disposal handled automatically. At Hoomanely, as we grew from MVP to a comprehensive pet healthcare platform, Stacked scaled effortlessly through modular features that are self-contained, shared services for common functionality, and consistent patterns that new features follow.
Key takeaways
- Stacked isn't just a state management solution, it's an architectural decision that impacts your entire development lifecycle.
- Choose it if you need clear, opinionated structure for medium-to-large apps, built-in navigation and dialogs, fast onboarding for new team members, and excellent testability without fighting the framework.
- For Hoomanely, Stacked was transformative, giving us the structure to scale our app while maintaining code quality, decisions that still serve us well today.