How Do Six Different Flutter Apps Share One Architecture?
By converging on the same three-layer structure, state pattern, DI approach, and router across every app — not by accident, but after years of rewriting modules that didn't share one.
At Block B Technologies, we build and maintain six Flutter apps: Color Vision Aid (accessibility), Rize (construction calculators), Culvert Studio (hydraulic engineering), Squib (transport management), Nova Mobile (AI companion), and RYX (HR management). They span different domains, different backends, and different levels of complexity. But they all share the same architectural foundation.
That consistency isn't an accident. After years of iteration — rewriting modules, refactoring state management, and debugging tangled dependency chains — we've converged on a set of patterns that work. Here's what they are and why they matter.
What Does Clean Architecture Actually Protect You From?
Cascading changes when a backend contract shifts — because the Domain layer has zero dependencies on Flutter, your backend, or your database, a backend swap only touches the Data layer.
Every app follows a three-layer structure: Presentation, Domain, and Data. The rules are simple. Presentation knows about Domain. Domain knows about nothing. Data implements Domain interfaces.
The folder structure looks like this:
lib/
features/
auth/
presentation/ # Screens, widgets, view models
domain/ # Entities, repositories (abstract)
data/ # API clients, local storage, repo impls
The Domain layer contains your business logic, entities, and abstract repository definitions. It has zero dependencies on Flutter, on your backend, on your database. When we switched Squib from a REST API to SignalR for real-time updates, the Domain layer didn't change at all. We swapped the Data layer implementation, and everything upstream continued working.
This separation feels like overhead on day one. By month three, it's saving you from cascading changes every time a backend contract shifts.
Why Provider Instead of Riverpod, Bloc, or Redux?
Provider is built into Flutter's ecosystem, needs no code generation step, and stays simple to test at the scale of six apps and 1600+ tests — the other three trade that simplicity for power we don't currently need.
| Option | Why Not, For Us |
|---|---|
| Riverpod | Codegen step adds build friction across 6 separate CI pipelines |
| Bloc | More boilerplate per feature than our release velocity needs |
| Redux |
Single global store fights the per-feature
ChangeNotifier pattern we standardized on
|
| Provider (chosen) | Built into Flutter's ecosystem, no codegen, one pattern across 1600+ tests |
We use Provider with ChangeNotifier
for state management across all six apps — simple to understand,
easy to test, and it performs well for the UI patterns we build. A
typical view model looks like this:
class AuthViewModel extends ChangeNotifier {
final AuthRepository _repo;
AuthViewModel(this._repo);
bool _loading = false;
bool get loading => _loading;
Future<void> signIn(String email, String pass) async {
_loading = true;
notifyListeners();
await _repo.signIn(email, pass);
_loading = false;
notifyListeners();
}
}
Every view model takes its dependencies through the constructor. No global state. No magic. The view model calls repository methods, updates its internal state, and notifies listeners. Widgets rebuild. That's it.
We've built apps with hundreds of screens using this pattern, and it scales well because each view model owns a small, focused piece of state. When a screen gets complex, we split it into multiple view models rather than building a monolithic state object.
What Does GetIt Buy You Over Manual Dependency Injection?
Loose coupling that survives test swaps: view models depend on abstract interfaces, so production wiring becomes fake wiring in tests with zero code changes. GetIt is our service locator, registering every repository, service, and view model at startup.
final sl = GetIt.instance;
void setupDependencies() {
sl.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(sl()),
);
sl.registerFactory(
() => AuthViewModel(sl()),
);
}
The key benefit is loose coupling. View models
depend on abstract repository interfaces, not concrete
implementations. In production, AuthRepository resolves
to AuthRepositoryImpl which calls a real API. In tests,
we register a FakeAuthRepository instead. Same view
model, different backing implementation, zero code changes.
Why GoRouter for Navigation Across Six Apps?
Deep linking that just works: define routes once and FCM push notifications resolve straight to the right screen. GoRouter also gives us type-safe routing and nested navigation for complex flows — all defined declaratively.
Each app has a single router configuration file that defines every route. Guards handle authentication checks. Shell routes handle persistent navigation bars. Sub-routes handle nested flows like onboarding or multi-step forms.
In practice: Nova Mobile's FCM push notifications deep link directly into specific AI sessions, and Squib links from notifications into specific loads or trips — no custom URL-parsing code per app.
Why Share One UI Kit Across Six Apps?
So one design fix — a button style, an accessibility correction, a spacing token — propagates to all six apps with a single package version bump. blockb_ui_kit is the private Flutter package that carries our buttons, cards, inputs, typography, and color tokens.
The UI kit enforces design constraints at the component level. You can't accidentally use a non-standard font size or a color that isn't in the palette, because the components don't expose those options. This is opinionated by design — it trades flexibility for consistency.
How Do You Keep 1600+ Flutter Tests From Becoming Flaky?
By wrapping provider calls made in initState with
addPostFrameCallback, and sharing one fakes/helpers
pattern across every app instead of reinventing test scaffolding per
project.
Our test suite includes unit tests for business logic and view models, widget tests for UI components and screens, and integration tests for critical user flows.
One pattern we've refined across all six apps is handling
Provider-dependent widgets in tests. When a widget calls a provider
method in initState, we wrap it with
addPostFrameCallback to ensure the provider is
available before the call executes. This avoids timing issues that
cause flaky tests.
We also maintain a fakes.dart file in each app's test
directory with fake implementations of every repository. Combined
with GetIt, swapping in fakes for any test is a one-liner. The test
infrastructure itself is a shared pattern —
widget_test_helpers.dart provides pump-and-settle
utilities, common widget wrappers, and golden test setup.
Good architecture doesn't mean more code. It means code in the right places. When a bug surfaces in production, clean architecture tells you exactly where to look — and more importantly, where you don't need to look.
The Key Lesson
Start with good architecture. Not because it's theoretically superior, but because it's practically necessary once your app grows beyond a few screens. The cost of clean architecture is paid upfront in boilerplate — folder structure, abstract classes, dependency registration. But the return compounds with every feature you add, every bug you fix, and every test you write.
We've been building Flutter apps for years. Every shortcut we tried in early projects — skipping the domain layer, using global state, hard-coding dependencies — came back as technical debt that cost more to fix than it saved. The patterns in this article aren't clever. They're pragmatic. And they work at scale across six very different apps.