If you're starting a new Flutter project in 2026, this article covers two things:
- What a modern Flutter project should look like (tech stack, architecture, engineering);
- 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();
}
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);
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_serializableNever hand-edit generated files, and
excludethem inanalysis_options.yamlto 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
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
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
Why go this heavy? Because on a team of dozens:
- 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.
- Compilation isolation = faster incremental builds. Changing one feature doesn't recompile the world.
- No collisions in parallel development. Different squads own different packages, so the conflict surface is small.
-
An independent design system = a single source of truth for UI consistency (the
design_systempackage), 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 owngogba_custom_lintpackage — 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 fromwarningtoerrorlevel:
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
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
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
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.
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/excludehandling - [ ] A single
verifyscript, run identically locally and in CI
Additionally for team projects:
- [ ] melos workspace + package boundaries (features / design_system / foundation)
- [ ] A
lintspackage to unify rules; consider custom lint - [ ]
dependency_overridesto 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)