Skip to main content

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

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

ElementConventionExample
Files and directoriessnake_caseuser_repository.dart
Types (classes, enums, mixins, extensions)UpperCamelCaseUserRepository
Members, variables, parameterslowerCamelCasefetchUser
ConstantslowerCamelCasemaxRetries
Private membersleading underscore_controller
Import prefixessnake_caseas 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 to core/ 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

UseWhen
StatelessWidgetThe widget is a pure function of its constructor arguments and inherited data
StatefulWidgetYou 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.