Project structure
How to lay out a Flutter project so it survives growth: directory conventions, naming, where to draw layer boundaries, and the widget composition habits that decide whether the codebase stays readable.
Contents
- What flutter create gives you
- Naming conventions
- Feature-first layout
- Layer boundaries
- Stateless and stateful widgets
- Composition habits
- Splitting into packages
- Analysis options
What flutter create gives you
my_app/
├── android/ native Android host project
├── ios/ native iOS host project
├── lib/
│ └── main.dart entry point
├── test/
├── pubspec.yaml dependencies, assets, fonts
└── analysis_options.yaml
Everything you write lives in lib/. The platform folders are real native
projects — you edit them for permissions, signing, icons, and plugin
configuration, and you keep them in version control.
Naming conventions
| Element | Convention | Example |
|---|---|---|
| Files and directories | snake_case | user_repository.dart |
| Types (classes, enums, mixins, extensions) | UpperCamelCase | UserRepository |
| Members, variables, parameters | lowerCamelCase | fetchUser |
| Constants | lowerCamelCase | maxRetries |
| Private members | leading underscore | _controller |
| Import prefixes | snake_case | as http |
Name the file after its primary type: UserRepository lives in
user_repository.dart. Suffix by role — _page.dart, _view.dart,
_controller.dart, _repository.dart, _model.dart — so a file's job is
obvious in a search result.
Feature-first layout
Group by feature, then by layer inside the feature. The alternative — a global
models/, views/, controllers/ split — forces you to touch four distant
directories to change one screen, and gets worse as the app grows.
lib/
├── main.dart
├── app/
│ ├── app.dart root MaterialApp, theme, router wiring
│ ├── router.dart
│ └── theme.dart
├── core/ shared, feature-agnostic
│ ├── network/ dio client, interceptors
│ ├── storage/
│ ├── errors/
│ ├── extensions/
│ └── widgets/ genuinely generic widgets
└── features/
├── auth/
│ ├── data/ repositories, data sources, DTOs
│ ├── domain/ entities, use cases (only if it earns its keep)
│ └── presentation/ pages, widgets, state
└── catalog/
├── data/
└── presentation/
Rules that keep this honest:
- A feature may depend on
core/, never on another feature's internals. If two features need the same thing, it moves tocore/or becomes its own package. core/widgets/is for widgets with no feature knowledge. A widget that mentions a domain type belongs to that feature.- Do not create
domain/with one-line use cases that only forward to a repository. Add the layer when there is behaviour to hold, not preemptively.
Layer boundaries
The dependency direction that works for most apps:
presentation -> domain (optional) -> data -> platform / network
widgets, state entities repositories, DTOs
- Data talks to the network, database, and platform. It converts DTOs to domain types so JSON shapes never reach the UI. See Networking and serialization and Local storage.
- Domain holds entities and rules that are independent of both Flutter and the transport.
- Presentation holds widgets and the state objects that feed them. See State management.
The practical test: nothing outside presentation/ should import
package:flutter/material.dart. If your repository imports Material, the
boundary has already leaked.
Stateless and stateful widgets
| Use | When |
|---|---|
StatelessWidget | The widget is a pure function of its constructor arguments and inherited data |
StatefulWidget | You need per-instance mutable state that survives rebuilds — controllers, tickers, subscriptions, focus nodes |
Reach for StatelessWidget first. Needing an AnimationController or a
TextEditingController is a real reason to be stateful; holding data that the
app owns is not — that belongs in a state management solution, and keeping it in
State makes it untestable and unshareable.
class CounterView extends StatefulWidget {
const CounterView({super.key, required this.initial});
final int initial;
@override
State<CounterView> createState() => _CounterViewState();
}
class _CounterViewState extends State<CounterView> {
late int _count = widget.initial;
@override
Widget build(BuildContext context) => TextButton(
onPressed: () => setState(() => _count++),
child: Text('$_count'),
);
}
Access the widget's fields through widget. — do not copy them into State
fields except as an initial value, because the widget can be rebuilt with new
arguments while the State object is reused. That mechanism is explained in
Lifecycle and Rendering pipeline.
Composition habits
Extract widgets into classes, not helper methods. A _buildHeader() method
returns a subtree that is rebuilt whenever the enclosing build runs and can
never be const. A const _Header() widget class can be skipped entirely by
the framework.
// Avoid
Widget _buildHeader() => Row(children: [...]);
// Prefer
class _Header extends StatelessWidget {
const _Header();
@override
Widget build(BuildContext context) => Row(children: [...]);
}
Mark everything const that can be. const widgets are canonicalised and
compared by identity, so the framework short-circuits their rebuild. Enable the
prefer_const_constructors lint and let the analyser find them.
Keep build cheap and free of side effects. It runs often and at times you
do not control. No network calls, no setState, no allocation of controllers.
Push state down, not up. A rebuild costs whatever is beneath it. Wrapping
the smallest possible subtree in the thing that changes — a ValueListenable, a
Consumer, an AnimatedBuilder — is the cheapest optimisation available.
Splitting into packages
When a codebase grows past a few features, promote shared code to local packages:
my_app/
├── packages/
│ ├── design_system/
│ ├── api_client/
│ └── shared_models/
└── app/
dependencies:
design_system:
path: ../packages/design_system
Benefits: enforced boundaries (a package cannot reach into the app), faster
targeted tests, and reuse across apps. Cost: more pubspec.yaml files and a
version-bump dance. Tools such as Melos automate the multi-package chores. Do
this when the boundary already hurts, not on day one.
Analysis options
include: package:flutter_lints/flutter.yaml
analyzer:
language:
strict-casts: true
strict-raw-types: true
errors:
invalid_annotation_target: ignore # noisy with json_serializable
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
linter:
rules:
- prefer_const_constructors
- prefer_const_declarations
- always_declare_return_types
- avoid_print
- unawaited_futures
strict-casts is the highest-value setting here: it stops dynamic from
silently becoming your type and catches JSON parsing mistakes at compile time.
Run flutter analyze in CI so the rules are enforced rather than suggested.
Next: Widgets and layout.