A Flutter app that starts as a handful of screens in a flat lib/ folder works fine until it doesn't — usually around the point a second developer joins, or a feature needs to be reused across two parts of the app, and nobody can confidently say where a given piece of logic should live. Here's an architecture that scales without over-engineering a small app on day one.
Table of Contents
- The Core Problem Architecture Solves
- Feature-First Folder Structure
- Layering Within a Feature
- Dependency Direction Rules
- When Clean Architecture's Full Ceremony Is Worth It
- A Working Example
- Common Mistakes
- FAQs
- Key Takeaways
The Core Problem Architecture Solves
Not "which folder structure is prettiest" — the actual problem is can a new team member find where a given piece of logic lives without asking, and can you change one thing without unpredictable ripple effects elsewhere. A flat, type-organized structure (all widgets in one folder, all models in another) fails the first test as the app grows — finding everything related to "checkout" means searching across five unrelated folders.
Feature-First Folder Structure
Organizing by feature rather than by type solves the discoverability problem directly:
lib/ features/ checkout/ data/ domain/ presentation/ profile/ data/ domain/ presentation/ core/ network/ theme/ widgets/
Everything related to checkout lives under features/checkout/ — a new contributor working on checkout doesn't need to understand the whole app's structure, just this one directory. core/ holds genuinely cross-cutting concerns (shared widgets, network client setup, theming) that multiple features depend on.
Layering Within a Feature
Within each feature, a lightweight three-layer split keeps concerns separated without requiring a full clean-architecture ceremony:
- data: API clients, local storage, data models matching external formats (JSON structures, database rows).
- domain: business logic, use cases, and domain models — the actual rules of your app, independent of how data is fetched or how it's displayed.
- presentation: widgets, view models/controllers (Riverpod providers or Bloc classes, per your state management choice), and UI-specific logic.
// data/checkout_repository.dart class CheckoutRepository { Future<Order> submitOrder(Cart cart) async { final response = await apiClient.post('/orders', cart.toJson()); return Order.fromJson(response.data); } } // domain/submit_order_usecase.dart class SubmitOrderUseCase { final CheckoutRepository repository; Future<Order> call(Cart cart) { if (cart.items.isEmpty) throw EmptyCartException(); return repository.submitOrder(cart); } } // presentation/checkout_controller.dart class CheckoutController extends StateNotifier<CheckoutState> { final SubmitOrderUseCase submitOrder; Future<void> submit(Cart cart) async { state = CheckoutState.loading(); try { final order = await submitOrder(cart); state = CheckoutState.success(order); } catch (e) { state = CheckoutState.error(e.toString()); } } }
Dependency Direction Rules
The rule that actually matters: presentation depends on domain, domain depends on data abstractions (not concrete implementations), data implements those abstractions. This means your business logic (domain layer) never directly imports an HTTP client or a specific database package — it depends on an interface, and the data layer provides the concrete implementation. This is what makes domain logic testable without spinning up real network calls, and what makes swapping a data source (REST to GraphQL, for instance) a data-layer-only change.
// domain/checkout_repository.dart (abstract) abstract class CheckoutRepository { Future<Order> submitOrder(Cart cart); } // data/checkout_repository_impl.dart (concrete) class CheckoutRepositoryImpl implements CheckoutRepository { final ApiClient apiClient; @override Future<Order> submitOrder(Cart cart) async { /* ... */ } }
When Clean Architecture's Full Ceremony Is Worth It
Full clean architecture (with separate entity/use-case/repository layers, dependency injection containers, and strict interface boundaries everywhere) earns its complexity for larger apps with multiple contributors, long expected lifespans, and genuine need to swap implementations (e.g., supporting both online and offline data sources with the same domain logic). For a small app, a single developer, or an MVP being built to validate a hypothesis (see my MVP development guide for the broader version of this tradeoff), the lighter three-layer structure above without the full ceremony is usually the better fit — you can always add stricter boundaries later if the app's lifespan and team justify it.
A Working Example
A common real decision point: should a ProductCard widget used in both the catalog and search features live in core/widgets/ or be duplicated in each feature? The answer depends on whether it's genuinely the same UI concept with the same behavior, or coincidentally similar today but likely to diverge — premature sharing of widgets that later need to diverge is its own maintenance cost, sometimes worse than initial duplication.
Common Mistakes
- Organizing by type instead of by feature in anything beyond a genuinely small app — this is the single most common structural mistake that makes codebases hard to navigate as they grow.
- Domain logic directly importing Flutter widgets or HTTP clients, coupling business rules to implementation details that should be swappable.
- Over-applying full clean architecture ceremony to a small app or MVP, adding boilerplate that slows early iteration without a team or lifespan that justifies it.
- No clear rule for where shared code lives, leading to either everything ending up in a bloated
core/or, conversely, extensive duplication because nobody's sure where shared code belongs.
FAQs
Do I need a dependency injection framework like get_it or injectable? Not strictly required for small apps — constructor injection alone works fine. DI frameworks earn their value once the dependency graph is large enough that manual wiring becomes tedious and error-prone.
Should I use clean architecture from day one on a new project? Generally no for a small app or MVP — start with the lighter feature-first, three-layer structure and add stricter boundaries if and when the app's actual growth justifies it.
How does this architecture interact with Riverpod or Bloc specifically? Cleanly — both fit naturally into the presentation layer as the state management mechanism, with your domain use cases as the actual logic they call into, regardless of which state management library you choose (see the Riverpod vs Bloc comparison for that separate decision).
What's the risk of over-organizing a small app too early? Slower initial development for a benefit (maintainability at scale) you may never need if the app stays small — match structural investment to actual expected growth and team size, not to what looks impressive in a blog post.
Key Takeaways
- Feature-first folder organization solves discoverability better than organizing by type as an app grows.
- The critical dependency rule: domain logic depends on abstractions, never on concrete data-layer implementations — this is what keeps business logic testable and swappable.
- Full clean architecture ceremony is worth it for larger, longer-lived, multi-contributor apps; a lighter three-layer structure is usually the better fit for smaller apps and MVPs.
Related Reading
- Flutter State Management: Riverpod vs Bloc
- Flutter vs React Native in 2026: An Honest Comparison
- MVP Development Guide: What Actually Matters in Month One
Next Steps
Architecting a Flutter app that needs to scale with your team, or want a second opinion on your current structure? Get in touch.