I’m Mohammad Jashem — a Senior Mobile Architect, and for the last 7+ years I’ve been the maintainer of the NCC App, the student-assistance app for Northwest Career College. It started on Flutter 1.x in 2019. The current build runs on Flutter 3.41.x stable, with Dart 3, Impeller, and Material 3. Same codebase. Same store listings — Google Play, App Store, Amazon Appstore. Zero major production incidents across the entire window.
This is the write-up I wish I’d had in 2019. Not “Flutter is great, just upgrade.” The actual migrations — the ones that bit — and the discipline that kept the app shippable. If you’re maintaining a Flutter app at the multi-year horizon, or building one you intend to keep, this is the practice.
The span#
Seven years is a long time in any framework. In Flutter it’s an era. I started on Flutter 1.2-ish, when StatefulWidget was the default answer to everything, platform channels were the only bridge to native, and null safety was a twinkle in the Dart team’s eye. The current build runs on Flutter 3.41.x, with Dart 3’s records and patterns, the Impeller rendering backend, Material 3 theming, and a toolchain that doesn’t resemble 2019’s at all.
What “long-term” actually means for a Flutter codebase is this: every assumption you baked in at the start — the widget composition patterns, the state management choice, the way you talked to the platform — has been invalidated at least once by a framework release. Not all at once. One breaking change per quarter, forever, for the life of the app.
Web frameworks can sit on a major version for half a decade. Flutter ships a stable every few months, and every stable has the potential to break something you wrote four years ago.
The migrations that actually hurt#
Most Flutter stables are boring in the best way. These are the ones that weren’t.
Riverpod 2 → 3. The biggest one I’ve absorbed recently, and the most representative of what state-management migrations actually cost. Riverpod 3 changed the API surface — StateController is gone, notifiers inherit differently, auto-dispose semantics moved, and the Freezed union methods (mapOrNull, maybeWhen, map) that the whole state machine relied on stopped generating cleanly until the codegen caught up. When I ran the analyzer on the migrated codebase it returned 386 issues — 291 errors, 95 warnings — across the feature set. That is the real shape of “we upgraded the state library.” Not a blog-post-sized diff. A week of fixing union patterns, re-checking auto-dispose behavior on every provider, and confirming no feature changed behavior while its syntax did. Zero functionality changes was the rule, and the only way to hold it was one feature at a time, each one shippable on its own.
Packages broke on every major. This is the part the framework docs don’t dwell on. A Flutter major doesn’t just move Flutter — it moves the Dart SDK, the analyzer, the code generator, and half your plugin tree. On a 76-module app, every major update was a cascade: a plugin that hadn’t published a compatible release yet, a transitive that pinned an old SDK, two packages that resolved to conflicting versions of the same underlying dependency. Resolving the conflict graph was routine engineering work — and it was the work, every quarter, before a single feature could ship on the new stable.
And we redesigned the app at the same time. Here’s the part that compounded everything: the update windows were also redesign windows. A major Flutter bump usually coincided with a product decision to refresh the UI — Material 2 to Material 3 being the obvious one. So the same branch that absorbed framework churn was also moving widgets, restyling screens, and rethinking flows. Two kinds of breakage on the same diff are hard to review and hard to bisect. The discipline I learned the hard way: even when the customer wants both at once, land them in layers — framework-stable first, redesign on top of a green build — never interleaved.
Null safety (Dart 2.12). The earlier big one. A forced, sound migration of the entire codebase. I did it in layered passes: leaf packages first (the pure Dart utilities with no widget tree), then feature modules, then the app shell last. Each layer was independently shippable, so the app never stopped releasing. The teams that got burned tried to do it on one heroic branch. That branch lives forever.
Material 2 → Material 3. Less catastrophic, more annoying. MaterialApp got new semantics, component constructors changed shape, and theming moved from a flat ThemeData to a ColorScheme-driven model that expects Material You color roles. The components that earned their keep — NavigationBar, the M3 Card, the M3 AppBar — I migrated deliberately. The ones that didn’t, I left on legacy. A blind “migrate everything to M3” pass is how you ship visual regressions you don’t notice for two releases.
Impeller. I’ll be honest about this one, because the temptation in a piece like this is to invent a dramatic Impeller war story. On this app, Impeller was largely transparent — shader-compilation jank disappeared, animations got smoother, and that was the news. The reason it was uneventful is the whole point of this article: the architecture kept rendering concerns inside the presentation layer, so when the rendering backend swapped, nothing in the domain or application layer cared. When the engine changes and your app doesn’t notice, that’s the architecture paying you back — not luck.
Dart 3 records, patterns, and sealed classes. Language features I adopted selectively. Records replaced a handful of ad-hoc tuple classes. Patterns made state-machine exhaustiveness real — switch over a sealed union of states warns at compile time if you miss a case (the Freezed unions are what make this concrete). What I didn’t do is rewrite working code to use the new hotness. A seven-year-old codebase that suddenly uses records everywhere is a PR no one can review.
Android Gradle Plugin and Xcode bumps. The unsexy ones that hurt most. Every year AGP moves a major, Gradle moves a major, the NDK drifts, Java LTS moves. Every year Xcode moves a major, the deployment target ratchets up, Swift evolves, and a signing workflow that worked last quarter breaks. These aren’t Flutter changes — they’re the native toolchain Flutter sits on top of, and they break your Flutter build just the same. I budget time every quarter to absorb the drift. Skip it for a year and you have a multi-week catch-up on a codebase that no longer builds clean.
Upgrade strategy#
Breaking changes don’t stop releases. They get absorbed into the release cadence. The strategy is a calendar, not a sprint.
One minor Flutter bump per sprint. Every two weeks, when a new stable lands, I bump on a quiet morning, resolve the package graph, run the app across the device matrix, and ship. If something broke, it broke small.
One major migration per quarter at most. Null safety. Material 3. Riverpod 3. These are quarter-sized efforts. They get their own branching strategy — feature-flagged, landed in layers, promoted through the staged rollout like any other release. Never two major migrations on the same branch. The blast radius of debugging two simultaneous breaking changes is not something you want to explain to a client.
Never ship on a Friday. Old rule. Still right. Friday ships are how you spend Saturday watching the vitals dashboards. I cut releases Monday through Wednesday, give the staged rollout 48 hours of real user traffic, and promote to 100% by Thursday — or hold for the next week.
How it stayed at zero major incidents#
Zero major production incidents across the maintenance window. Not “few” — zero. No data loss, no auth outage, no crash loop. I’m not going to pretend that record came from a deep automated test suite. It didn’t. It came from a release pipeline that treats every change as a staged, reversible rollout — and from being honest about where a regression would actually hurt.
Manual verification on the paths that hurt a student. The load-bearing flows — auth, payments, the API contract, attendance, document upload — get eyes on them on a real device before promotion, every time they change. Not a sprawling automated suite; deliberate, scoped manual passes on the paths where a regression actually hurts. I don’t chase 100% coverage as a vanity number — I cover what would hurt a student. There’s a future where this app carries a proper widget- and integration-test layer, and that’s on the roadmap. The honest present is manual verification plus the pipeline below.
Staged rollouts on every store. Play’s internal → closed → open → production ladder. TestFlight for iOS, then a phased App Store release across the 1% → 20% → 50% → 100% curve over a week. Amazon’s live testing group, then production. If something wobbles, it wobbles for a few hundred users, not a few hundred thousand.
Monitoring that sees the wobble before the ticket arrives. The stores’ vitals dashboards on both platforms (Play Console, App Store Connect) and an uptime monitor on the API. When something does break — a rare GPU driver crash on a specific Android minor version, an iOS release that changed push semantics — I see it in the dashboard before the support inbox fills.
Conservative dependency upgrades. I read changelogs. I read issue trackers. I wait a beat on major bumps to see who else breaks first. A package that drops a breaking major on pub.dev is not a fire drill — it’s a “next sprint, after the early adopters have filed the bugs.”
The release pipeline itself is half the story. The whole flow — build, sign, upload to three stores, staged rollout, vitals watch — is automated end to end. That automation is the CI/CD stack that kept it shippable, and it’s the reason a migration like Riverpod 3 could go through the same release path as a feature. Reproducible pipeline, staged rollout, rollback button. Without that, “conservative and staged” isn’t a strategy — it’s a hope.
Architecture that aged well#
The architecture decisions that age well are not the fashionable ones. They’re the boring ones.
Clean Architecture boundaries. The app is feature-first Clean Architecture — every feature is a self-contained module with the same five layers: domain (entities, pure Dart, no Flutter), application (use cases, state notifiers), infrastructure (repositories, DTOs, API services), presentation (pages, widgets), and shared (Riverpod providers). When Material 3 lands, that’s a presentation task. When Dart 3 patterns land, that’s a domain task. The two don’t collide. I made that refactor in year two and it has held for five years since — it’s the reason Riverpod 3 was a syntax migration and not a rewrite.
Feature modules, not a lib/screens soup. The app carries over seventy feature modules — academic (advising, attendance, class schedule, certification, externship, the financial ledger), campus services (map, safety, parking, facilities), student services (payments, profile, digital ID and QR, document upload, transcript requests, veterans’ benefits), and communication (the live feed, feedback, IT support, messaging). Each module has its own five layers. A change to attendance doesn’t touch payments. Reviews stay small.
State that stays inside its feature. The app uses Riverpod, and the reason it’s survived seven years is that the providers are feature-scoped, not app-global. A payment flow doesn’t read from the attendance store. Cross-feature communication goes through a defined interface, not a global singleton. A global store is a coupling bomb.
One dio client per backend. Four configured Dio clients — the main authenticated API, the WordPress CMS, the feed service, and the externship service — each with its own base URL and interceptors. Auth headers live in one interceptor (Bearer token, sign-out on 401), not copy-pasted per request.
A shared platform, not a one-off. Auth, the feed, and core utilities live in reusable crewlix_* packages shared across multiple apps — the same packages that power the Crewlix HR app. This isn’t one app’s lib/ folder — it’s a small platform. Custom local packages extend it. When a fix lands in crewlix_core, every app on the platform gets it. The architectural walkthrough of how those package boundaries are drawn is in the Crewlix clean-architecture deep dive.
Dependency discipline#
The pub.dev ecosystem moves fast and dies quietly. A package that was the standard answer in 2020 is unmaintained by 2023.
Before I add a dependency, it passes a simple check: is it maintained, does it have tests, how big is its transitive surface, and could I vendor it in a day if the maintainer walked away. A 200-line utility that pulls in 40 transitive packages is not a small dependency — it’s a supply-chain surface bundled as one.
Float on ^, lock with the lockfile. I won’t pretend I pin every dependency to an exact version. The app floats on caret ranges with pubspec.lock committed — reproducible builds without dependency-update theatre. The lockfile is the source of truth for what actually shipped; the caret ranges keep security and patch updates reachable without a manual bump on every transitive. That’s the honest tradeoff, and it has held for seven years.
The hidden tax on a dependency that goes quiet. I’ve watched packages work fine for two years and then break the day a platform moves underneath them — an iOS deployment-target bump that a native binding was never recompiled against, and the app crashes on launch on the new iOS. The fix is ugly: find a replacement, rip out the old integration, re-validate the paths that depended on it. That’s the cost of a dependency that went quiet. It doesn’t break today. It breaks the day the platform moves.
The CI/CD spine#
None of the above holds without a reproducible pipeline. A merged pull request on this app builds, signs, and ships to all three stores without a human at a console — Drone CI builds and uploads Android (Google Play and Amazon), Xcode Cloud handles iOS signing inside Apple’s trust boundary, Fastlane drives the store uploads, a Bitbucket Pipelines step runs an AI code review on every change, and Google Chat reports every transition. Android release builds are obfuscated with ProGuard. A git tag is the release button for a test build — anyone on the team pushes a tag and gets a signed APK in the distribution channel. The Android leg runs in minutes, not hours; the iOS leg on Xcode Cloud is in the same ballpark — fast enough that the pipeline is never the bottleneck on a release.
The pipeline is what makes the seven-year horizon possible. Every migration, every breaking change, every staged rollout goes through it. The NCC App is still shippable after seven years because the discipline was never “remember to test before release.” It was “the pipeline won’t let you release without going through the stages.” That’s the difference between a maintenance practice and a maintenance prayer.
What I’d tell someone starting a long-term Flutter app today#
Keep a hard seam between your domain layer and your presentation layer. Group by feature, not by file type. Pick one state manager and keep state inside its feature. Build the CI/CD pipeline on day one — not “when we have time.” And when the framework changes and your app doesn’t notice, recognize that as the architecture paying you back, not as permission to stop paying attention.
If you’re starting a Flutter app you intend to keep — or maintaining one that’s starting to fight back — that’s the problem I solve. Get in touch, and I’ll tell you straight whether your codebase is a six-month cleanup or a two-week hardening. The case study for the app behind this piece is in the NCC App case study.
Frequently Asked Questions#
How long can a Flutter app be maintained? Seven-plus years here, and counting — Flutter 1.x in 2019 through 3.41.x today, across three stores. The deciding factor isn’t Flutter; it’s keeping a seam between your domain logic and the framework so the codebase survives version churn.
How do you migrate Flutter state management (Riverpod 2 to 3) on a large app?
One feature at a time, each one shippable, with zero functionality changes as the rule. On this app the Riverpod 3 migration surfaced 386 analyzer issues — mostly Freezed union methods and StateController to Notifier changes — resolved feature-by-feature rather than on a single heroic branch.
How do you ship a Flutter app to three stores? Drone CI builds and uploads Android (Google Play and Amazon), Xcode Cloud handles iOS signing inside Apple’s trust boundary, Fastlane drives the uploads, and a git tag triggers test builds — fully automated, with Google Chat reporting every transition.
How do you maintain a Flutter app with zero major incidents? Not by chasing 100% test coverage. By staged three-store rollouts (internal → closed → phased production), manual verification on the load-bearing flows, store vitals and an API uptime monitor, and conservative dependency upgrades — all gated by a reproducible CI/CD pipeline.

