DEV Community

Hamber
Hamber

Posted on

Modern Flutter Best Practices for 2026

If you're starting a new Flutter project in 2026, this article covers two things:

  1. What a modern Flutter project should look like (tech stack, architecture, engineering);
  2. The trade-offs between an OPC (One Person Company) project and an enterprise-grade production project.

Everything below is grounded in two real projects:

  • go_gba — a GBA emulator App built by a single developer (single package, shipped on the App Store / Google Play, now at v3.12). A textbook OPC.
  • AppX (pseudonym) — a high-traffic consumer App maintained by a large cross-platform team (melos monorepo, multiple flavors, multiple environments). A textbook enterprise-grade project.

Both projects share the same modern Flutter foundation, but make completely opposite bets on "complexity investment." Comparing them tells you far more about when to reach for the heavy machinery than looking at either one alone.


0. The Bottom Line (TL;DR)

In 2026, several choices have moved from "optional" to "default":

Dimension 2026 default Why
State management Riverpod 3 (+ code generation) Compile-time safety, testable, no BuildContext dependency — the de facto community standard
Routing go_router (declarative + type-safe routes) Officially recommended; deep links / nested navigation are first-class
Data models freezed 3 + json_serializable Immutable, pattern matching, copyWith — kills hand-written boilerplate
i18n slang Type-safe, compile-time checked, an order of magnitude nicer than the official intl
Version pinning fvm (pin the Flutter SDK version) The end of "works on my machine"
Code generation build_runner freezed / riverpod / slang / routes / assets all depend on it
Lint Strict lint + custom lint rules Turns team conventions into machine-enforceable rules
Crash / analytics Firebase Crashlytics + Analytics (or equivalent) A production App without observability is flying blind

Details below.


1. Tech Stack: The 2026 "Standard Recipe"

The two projects agree closely on their core libraries, which itself is evidence that consensus has formed.

1.1 State Management — Riverpod 3

Stop agonizing over Provider / Bloc / GetX. Riverpod 3 is already the default answer for new projects.

  • go_gba: flutter_riverpod: 3.3.2, hand-written providers.
  • AppX: riverpod_generator + riverpod_annotation, full code generation.

The difference is whether you adopt code generation:

// Hand-written (good enough for OPC, directly readable)
final gameLibraryProvider = FutureProvider<List<Game>>((ref) async {
  return ref.watch(gameRepositoryProvider).loadAll();
});

// Generated (recommended for enterprise; stricter types, safer refactors)
@riverpod
Future<List<Game>> gameLibrary(GameLibraryRef ref) {
  return ref.watch(gameRepositoryProvider).loadAll();
}
Enter fullscreen mode Exit fullscreen mode

Recommendation: Go straight to Riverpod 3 on new projects. Team projects should use the generator (with riverpod_lint enforcing conventions); solo projects are perfectly fine hand-writing — don't add the mental overhead of code generation just to look "advanced."

1.2 Routing — go_router

Both projects use go_router: 17.x. AppX additionally adopts go_router_builder (type-safe routes: route parameters go from String to compile-time-checked objects).

// go_router_builder: routes ARE types, no more string concatenation for navigation
context.go(GameDetailRoute(gameId: game.id).location);
Enter fullscreen mode Exit fullscreen mode

Recommendation: go_router is the only no-brainer. Deep links, Web support, and nested Shell navigation all rely on it. The builder is worth it for team projects; optional for solo.

1.3 Data Models — freezed 3

AppX uses freezed across the board to define immutable models, paired with json_serializable for automatic serialization. This is the default way to handle data classes in 2026: immutability, copyWith, when/map pattern matching, and value equality — all generated for you.

Note the naming convention for generated files (worth copying from AppX):

  • *.f.freezed.dart — freezed-generated copyWith/when/map
  • *.f.g.dart — freezed + JSON serialization
  • *.g.dart — riverpod / standalone json_serializable

Never hand-edit generated files, and exclude them in analysis_options.yaml to avoid lint noise.

1.4 i18n — slang

Both projects abandoned the official intl arb workflow in favor of slang. Reasons: type safety (t.home.title instead of AppLocalizations.of(context)!.homeTitle), compile-time missing-key checks, and support for plurals / parameters / namespaces.

An enterprise-grade detail: AppX's slang output is not committed (strings*.g.dart stays out of the repo) and is generated on demand locally / in CI. This reduces merge conflicts but requires CI to run i18n generation before the build. OPC projects can just commit the output for simplicity.

1.5 Networking

  • go_gba: plain dio, simple and direct.
  • AppX: dio + dio_http2_adapter (HTTP/2) + native_dio_adapter (uses the native network stack) + in-house networking / WebSocket infrastructure packages (unified team wrappers).

This is the first classic fork between OPC and enterprise: solo projects use bare dio; teams extract the network layer into standalone packages to unify interceptors, retries, auth, telemetry, and certificate pinning.


2. Architecture: Different "Investment Budgets" for Layering

This is where the two projects diverge the most, and where "complexity is an investment" shows most clearly.

2.1 OPC: feature-by-layer (go_gba)

go_gba uses the classic slice-by-technical-layer directory:

lib/
├── core/          # emulator / services / analytics / errors / theme
├── data/          # datasources / repositories / adapters / platform
├── domain/        # business entities and use cases
├── providers/     # Riverpod providers
├── pages/         # organized by feature page: home / play / settings / toolkit ...
├── widgets/       # shared components
├── router/        # go_router config
└── i18n/          # slang
Enter fullscreen mode Exit fullscreen mode

Characteristics: one package does it all. pages/ has sub-directories per feature; data/domain are shared globally. For a project maintained by one person, this structure carries the lowest mental load — you can fit the whole dependency graph in your head and don't need package boundaries to force decoupling.

2.2 Enterprise: feature-first + Clean Architecture inside each feature (AppX)

AppX is a melos monorepo, split at the top level into three kinds of packages by responsibility:

apps/
└── app/                     # app shell: main_dev / main_prod / main_test (multiple entrypoints)
packages/
├── features/                # business feature packages, each self-contained:
│   ├── home/  feature_a/  feature_b/  auth/  ai/ ...
├── design_system/           # design system: colors/typography/spacing/components/chart
├── data_hub/  router/  routes/  env/  foundation/  push/  flavor_config/
└── lints/                   # team's custom lint rules package
plugins/                     # native plugins: various platform-capability wrappers
third_party/                 # third-party wrappers
Enter fullscreen mode Exit fullscreen mode

And each feature package internally is a full Clean Architecture:

packages/features/home/lib/src/
├── application/    # coordinators / services (use-case orchestration)
├── domain/         # entities / repositories (interfaces)
├── data/           # datasources (local/remote) / dtos / mappers / repositories_impl
└── presentation/   # pages / widgets / controllers
Enter fullscreen mode Exit fullscreen mode

Why go this heavy? Because on a team of dozens:

  1. Package boundaries = enforced decoupling. Feature A cannot directly import Feature B's internal implementation — only its public API. You can't achieve this with directory conventions; you need packages.
  2. Compilation isolation = faster incremental builds. Changing one feature doesn't recompile the world.
  3. No collisions in parallel development. Different squads own different packages, so the conflict surface is small.
  4. An independent design system = a single source of truth for UI consistency (the design_system package), instead of every page hard-coding its own colors.

2.3 The Key Judgment: When Should You Upgrade From an OPC Structure to a Monorepo?

Don't start with a monorepo. This is a hotspot for over-engineering. Upgrade only when the signals appear:

  • The codebase grows beyond what one person can "hold in their head";
  • 2+ developers need to work on different modules in parallel;
  • Incremental compilation starts getting noticeably slow;
  • Arguments over "which module does this logic belong to" start happening;
  • You need to reuse a single design system / network layer across multiple Apps or flavor outputs.

Until then, go_gba's single-package-with-clear-layering is the most cost-effective structure.


3. Engineering: Turning Conventions Into Machine Enforcement

The maturity of a modern Flutter project is largely reflected in how many conventions are machine-enforced rather than left to human discipline.

3.1 Strict Lint (both projects do it, at different intensities)

  • go_gba: based on leancode_lint, and it built its own gogba_custom_lint package — encoding project-specific conventions (e.g. "must go through a certain service instead of calling the API directly") as custom lint rules. Worth doing even solo, because it's a guardrail for "future you."
  • AppX: an extremely strict analysis_options.yaml, promoting many rules from warning to error level:
analyzer:
  language:
    strict-casts: true       # forbid implicit type casts
    strict-inference: true   # forbid inference falling back to dynamic
  errors:
    always_declare_return_types: error
    avoid_void_async: error
    prefer_single_quotes: error
    require_trailing_commas: error   # enforce trailing commas → better diffs and formatting
Enter fullscreen mode Exit fullscreen mode

Recommendation: Max out lint on day one of a new project. strict-casts + strict-inference are two low-cost, high-reward switches. Team projects must have a shared lints package to unify rules.

3.2 Custom Lint — the 2026 Power Move

Both projects do this, and many people overlook it: use custom_lint to compile your team's/project's verbal conventions into static checks.

A convention written in a doc goes unread; written as a lint rule, a violation lights up red. go_gba did this even as a one-person project — proof that the ROI is high enough.

3.3 Version Pinning — fvm Is a Necessity

AppX uses fvm to pin the Flutter SDK to an exact version, committed in .fvmrc. Everyone on the team, CI, and local runs all use the same SDK.

Team projects without fvm periodically bleed time to "it won't compile on my end." Solo projects should use it too, mainly to prevent a global upgrade from breaking the project.

3.4 The Code-Generation Pipeline Must Be "Ordered"

A hard-won lesson from AppX (written in its AGENTS.md): codegen cannot run in parallel. Because packages have dependencies (design_system's generated output is consumed by features), running in parallel causes AssetNotFoundException. So it wrote codegen.sh to generate in dependency-graph order, leaf packages first.

# Correct: in dependency order
melos run codegen   # → bash script/codegen.sh

# Wrong: parallel melos exec build_runner → random failures
Enter fullscreen mode Exit fullscreen mode

Takeaway: monorepo codegen must explicitly manage ordering. Single-package projects don't have this problem — one dart run build_runner build -d does it.

3.5 Git Hooks — Move Discipline Left, Before Push

After melos bootstrap, AppX automatically installs versioned git hooks (pre-push runs a lint attribution check). This way "you must pass validation before committing" isn't something people have to remember — it's intercepted automatically at push time.

Paired with this is a unified verify.sh (format check + per-package analyze across the whole workspace), and local and CI run the exact same script — this is crucial: passing locally = passing CI, eliminating the "green locally, red in CI" finger-pointing.

3.6 Multi-Environment / Multi-Entrypoint (an enterprise trait)

AppX has three entrypoints: main_dev.dart / main_prod.dart / main_test.dart, paired with generated environment variables (env.impl.dart) and per-flavor config (flavor_env.impl.dart). One codebase produces dev/prod, plus different flavor outputs, via build parameters.

go_gba, as a single product, doesn't need any of this. Multi-flavor / multi-channel packaging is a textbook "tax you only pay at enterprise scale."


4. Observability: The Eyes of a Production App

go_gba, despite being a one-person project, does observability thoroughly:

  • firebase_crashlytics — crash collection
  • firebase_analytics — user behavior
  • firebase_remote_config — remote config / gradual rollout switches
  • firebase_ai — AI capabilities

This is worth emphasizing: observability isn't an enterprise privilege — an OPC needs it more. A solo developer has no QA team and no support agents relaying issues; when something breaks in production, all you have is the Crashlytics stack trace and the Analytics funnel to diagnose it yourself. remote_config lets you kill a broken feature without shipping a release — a lifesaver for indie developers.

Enterprise projects typically use an in-house or heavier APM (AppX uses sentry_flutter), but the core idea is identical: crashes, performance, and behavior — three data streams, none optional.


5. OPC vs Enterprise: A Decision Matrix

Condensing the differences into one table. On the left, "the baseline both should do"; on the right, where they fork.

5.1 The Shared Baseline (do this regardless of scale)

Item Notes
Riverpod 3 + go_router + freezed + slang Modern foundation, no compromise
Strict lint (strict-casts / strict-inference) Max it out on day one
build_runner code generation Kill boilerplate
Observability (crash + analytics + remote config) Mandatory in production; OPC needs it especially
Clear layering (data / domain / presentation) Layer even in a single package
Custom lint rules High ROI, worth it even solo

5.2 The Fork Points

Dimension OPC (e.g. go_gba) Enterprise (e.g. AppX)
Repo structure Single package, layered directories melos monorepo, packages as boundaries
Architecture depth feature-by-layer, globally shared domain Independent Clean Architecture per feature
State management Hand-written Riverpod is enough Riverpod generator + riverpod_lint enforcement
Network layer Bare dio Extracted into standalone packages; unified interceptors/auth/HTTP2/WS
Design system A single theme directory Standalone design_system package (single UI source)
Multi-environment Single entrypoint Multiple flavor entrypoints + multi-channel
codegen One command Ordered generation script by dependency graph
CI / discipline Local scripts + store release git hook + verify script (local = CI) + full pipeline
Dependency versions Exact pinning is enough dependency_overrides unifies versions across the workspace
Optimization goal Iteration speed, lowest mental load Maintainability, parallelism, consistency

5.3 The One-Line Principle

OPC optimizes for "speed of change"; enterprise optimizes for "safety of change."

On a solo project, your biggest cost is your own attention, so cut every bit of complexity that needs "maintaining" (monorepo, extra abstraction layers, multi-channel packaging).
On a team project, your biggest cost is communication and incidents, so pay for "boundaries" and "enforcement" (package isolation, git hooks, unified versions).


6. The Two Most Common Mistakes

  1. OPC over-engineering: one person puts a to-do App on a melos monorepo with four-layer Clean Architecture per feature. The result: 80% of the time spent maintaining scaffolding instead of building features. Complexity is an investment; below the scale threshold, it's pure loss.

  2. Enterprise under-investment: five people cram into one lib/, with no package boundaries, no unified lint, codegen run by hand, and local vs CI scripts out of sync. The result: every merge is a disaster and "it works" is down to luck. Skip the tax you owe, and you'll pay it back later with interest.

To judge which tier you're in, don't look at "how professional I want to seem" — look at your actual current headcount and codebase size. Upgrade when the scale arrives. That's the only correct sequence.


Appendix: 2026 Starter Checklist

Day one of a new project:

  • [ ] fvm use <version> to pin the Flutter SDK, commit .fvmrc
  • [ ] Riverpod 3 + go_router + freezed + slang as the foundation
  • [ ] Max out strict lint in analysis_options.yaml (strict-casts, strict-inference)
  • [ ] Wire up crash + analytics + remote config (Firebase or equivalent)
  • [ ] Layered directories (even in a single package): data / domain / presentation
  • [ ] Generated-file naming convention + .gitignore/exclude handling
  • [ ] A single verify script, run identically locally and in CI

Additionally for team projects:

  • [ ] melos workspace + package boundaries (features / design_system / foundation)
  • [ ] A lints package to unify rules; consider custom lint
  • [ ] dependency_overrides to unify versions across the workspace
  • [ ] git hook (pre-push validation)
  • [ ] Multiple flavor entrypoints, ordered codegen script
  • [ ] AGENTS.md / CONTRIBUTING spelling out conventions (especially traps like codegen ordering)

Top comments (0)