Why Architecture Matters More Than You Think
Most Flutter tutorials show you how to build a screen. Nobody shows you what happens when you have 40 screens, 6 developers, a live app with paying users, and a two-week deadline to ship a new feature without breaking what already exists.
That's the problem architecture actually solves. Not the 3-screen demo. The 40-screen production app that has to keep working while you're adding to it.
I've built Flutter apps that scaled from zero to 100k+ users. I've also inherited codebases that made me genuinely consider a career change. This is what I learned about the difference between the two.
The Layer-First Trap
When you start a Flutter project, the natural instinct is to organise by layer:
lib/
├── models/
├── services/
├── repositories/
├── providers/
├── screens/
└── widgets/This feels clean. For your first app, it is clean. But the moment your project grows past 10 screens and 5 developers, this structure starts fighting you.
Here's the problem: a single user story — say, "show the user's prayer times" — now requires touching 6 folders. You need to open models/prayer_time.dart, services/prayer_api.dart, repositories/prayer_repository.dart, providers/prayer_provider.dart, screens/prayer_screen.dart, and widgets/prayer_card.dart.
On day one, you know where everything is. On day 180, after 3 developers have added to each folder, navigating the codebase feels like archaeology. You're not reading code — you're excavating it.
The deeper problem: you can't delete a feature. If you want to remove the prayer times feature, you have to hunt through every folder to find all the pieces. Something will get left behind. Something always gets left behind.
Feature-First: What It Actually Looks Like
Feature-first architecture inverts the organisation. Instead of grouping by technical layer, you group by product feature. Everything that belongs to one feature lives in one folder.
lib/
├── core/
│ ├── router/
│ │ └── app_router.dart
│ ├── theme/
│ │ ├── app_theme.dart
│ │ └── app_colors.dart
│ ├── network/
│ │ └── dio_client.dart
│ └── utils/
├── shared/
│ ├── widgets/
│ │ ├── app_button.dart
│ │ └── app_loading.dart
│ └── models/
│ └── api_response.dart
└── features/
├── quran/
│ ├── data/
│ │ ├── quran_api.dart
│ │ └── quran_repository.dart
│ ├── domain/
│ │ └── quran_models.dart
│ └── presentation/
│ ├── quran_screen.dart
│ ├── quran_provider.dart
│ └── widgets/
│ ├── ayah_card.dart
│ └── surah_list_tile.dart
├── settings/
│ ├── data/
│ ├── domain/
│ └── presentation/
└── bookmarks/
├── data/
├── domain/
└── presentation/Now, when someone joins the team and needs to work on the Quran feature, you point them at features/quran/. Everything they need is there. When you ship a new version and need to audit what changed, you look at one folder. When a feature gets cut before release, you delete one folder. Clean.
Clean Architecture Inside Each Feature
Feature-first is about where things live. Clean architecture is about how they talk to each other. I use a simplified three-layer model inside each feature:
Data layer — API calls, local database, external SDKs. Nothing in here knows about Flutter widgets. It only speaks in raw data.
Domain layer — your Dart models and business logic. Pure Dart. No Flutter imports. No HTTP packages. This layer defines what your feature is, independent of how it gets its data or how it displays it.
Presentation layer — screens, widgets, and Riverpod providers. This layer knows about Flutter. It reads from the domain layer and reacts to user input.
The rule is simple: dependencies only point inward. Presentation depends on Domain. Domain depends on nothing. Data depends on Domain (it implements domain interfaces). This means you can swap your REST API for GraphQL or your Hive database for SQLite without touching a single widget.
Riverpod: State Management That Actually Scales
I've used Provider, Bloc, GetX, and Riverpod in production. Riverpod is the only one I didn't eventually regret.
Here's why. In Provider, you register providers at the top of your widget tree. If you forget to add a ChangeNotifierProvider in the right place, you get a runtime error. Riverpod catches this at compile time. That difference sounds small. After your tenth 3am production crash, it stops sounding small.
The pattern I use for almost every async feature is AsyncNotifier:
// domain/quran_models.dart
class Surah {
final int number;
final String name;
final int ayahCount;
const Surah({required this.number, required this.name, required this.ayahCount});
}
// presentation/quran_provider.dart
@riverpod
class QuranNotifier extends _$QuranNotifier {
@override
Future<List<Surah>> build() async {
return ref.watch(quranRepositoryProvider).getSurahs();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(quranRepositoryProvider).getSurahs(),
);
}
}This single class handles loading, success, and error states — no isLoading booleans scattered across the codebase. In the UI:
class QuranScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final surahsAsync = ref.watch(quranNotifierProvider);
return surahsAsync.when(
loading: () => const AppLoading(),
error: (e, _) => ErrorView(message: e.toString()),
data: (surahs) => SurahListView(surahs: surahs),
);
}
}The UI reacts to state. It never manages state. That separation is everything.
GoRouter: Navigation That Doesn't Fight You
Navigation in Flutter is deceptively complex. Navigator.push works fine until you need deep links, web URL support, nested navigators, or auth guards. Then it falls apart fast.
GoRouter solves all of this with a declarative, URL-based routing system:
final appRouter = GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isAuthenticated = ref.read(authProvider).isLoggedIn;
if (!isAuthenticated && state.matchedLocation != '/login') {
return '/login';
}
return null;
},
routes: [
GoRoute(path: '/', builder: (_, __) => const HomeScreen()),
GoRoute(
path: '/surah/:number',
builder: (_, state) => SurahScreen(
number: int.parse(state.pathParameters['number']!),
),
),
GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()),
],
);Deep links, web support, and auth guards — all in one place. When your product manager asks for a shareable link to a specific Surah, you have it in 5 minutes.
Dependency Injection Without the Pain
Riverpod doubles as a DI container. Instead of setting up a separate DI framework, I define my dependencies as providers:
// core/network/dio_client.dart
@riverpod
Dio dioClient(DioClientRef ref) {
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
dio.interceptors.add(AuthInterceptor(ref));
return dio;
}
// features/quran/data/quran_api.dart
@riverpod
QuranApi quranApi(QuranApiRef ref) {
return QuranApi(ref.watch(dioClientProvider));
}
// features/quran/data/quran_repository.dart
@riverpod
QuranRepository quranRepository(QuranRepositoryRef ref) {
return QuranRepositoryImpl(
api: ref.watch(quranApiProvider),
localDb: ref.watch(hiveBoxProvider),
);
}In tests, you override any provider with a mock in a single line. No complicated test setup. No global state to reset between tests.
Testing Your Feature-First App
The biggest advantage of this architecture isn't the folder structure or the state management. It's testability.
Because each layer is decoupled, I can test each in isolation:
// Test the repository without a real API
void main() {
test('QuranRepository returns cached data offline', () async {
final container = ProviderContainer(
overrides: [
quranApiProvider.overrideWithValue(MockQuranApi()),
hiveBoxProvider.overrideWithValue(FakeHiveBox(seedData: mockSurahs)),
],
);
final repo = container.read(quranRepositoryProvider);
final result = await repo.getSurahs();
expect(result.length, equals(114));
expect(result.first.name, equals('Al-Fatiha'));
});
}No emulators. No network calls. No flaky tests that fail because the API is down at 2am during your CI run.
The Real Mistake I Almost Shipped
In the first version of the Al Quran app, I put translation-switching logic directly inside the QuranScreen widget. It was fast to write. It worked in testing. Three weeks before launch, we decided to add a split-screen mode where two translations could be shown side-by-side.
To add that feature, I had to rewrite the entire screen. Every piece of business logic was tangled with UI code. Extracting it took four days. Four days that we didn't have.
If the translation logic had lived in a QuranNotifier from the start, adding split-screen would have been a UI-only change — two to three hours, not four days.
The lesson: the effort to write clean architecture on day one is a few extra hours. The cost of not doing it is paid in the worst possible moments — mid-feature, mid-sprint, mid-deadline.
Performance: What Architecture Can and Can't Fix
A common misconception: clean architecture makes apps slower because of all the abstraction layers. In my experience, the opposite is true — not because abstraction is fast, but because it makes performance problems visible.
When your data fetching logic is in one place (QuranRepository), you can add caching in one place. When your state management is in AsyncNotifier, you can see exactly how many rebuilds are happening. When everything is tangled inside widgets, you can't see anything.
Specific things I did for performance that the architecture enabled:
- Selective rebuilds: Riverpod's
select()lets widgets rebuild only when the specific slice of state they care about changes — not every time any state changes. - Lazy loading: Surahs are loaded on demand, not all at once. The repository pattern made this a one-line change.
- Background caching: Translations are pre-fetched for the next Surah while the user reads the current one. This runs in an isolate, and the architecture made isolates easy to introduce without refactoring the UI layer.
Complete Feature Structure in 30 Seconds
If I had to summarise the architecture in a checklist:
- Group by feature, not by layer
- Three layers inside each feature: Data → Domain → Presentation
- Domain layer is pure Dart — no Flutter, no HTTP
- Riverpod for all state, including async loading and errors
- GoRouter for all navigation — deep links work on day one
- Providers as DI — override any dependency in tests with one line
- Never put business logic inside a Widget
Every one of these rules has been violated by me at some point, and I paid for it. These aren't theoretical best practices — they're scar tissue from real production problems.
Frequently Asked Questions
Why use feature-first architecture over the standard layer-first approach?
Layer-first works fine for small projects but breaks down as the codebase grows. Feature-first means all code for one feature lives in one folder — making it easy to onboard developers, delete features, and understand scope at a glance. You navigate by what you're building, not by what technical role the file plays.
Why Riverpod over Bloc or Provider?
Provider works but has runtime errors that Riverpod catches at compile time. Bloc is powerful but verbose — a simple fetch operation requires 3 files. Riverpod's AsyncNotifier gives you loading/error/data states in one class, with zero boilerplate, and the provider override system makes testing trivially easy.
Is this architecture overkill for a small app?
For a 3-screen personal project, yes — it's overkill. For anything you're shipping to real users and expect to maintain for 12+ months, no. The overhead to set it up is a few hours. The cost of refactoring a messy codebase mid-feature is measured in days. I've paid both prices.
How do you handle shared state between features?
Shared state — like the currently authenticated user or app-wide settings — lives in the core/ layer as a Riverpod provider. Features read from it but don't own it. This prevents circular dependencies and keeps each feature's scope clean.
What about code generation — is it worth the setup?
Yes, for anything beyond a prototype. Riverpod's @riverpod annotation with build_runner eliminates manual provider boilerplate. GoRouter's generator handles type-safe route parameters. The one-time setup cost of build_runner is paid back within the first week of development.