Three months into a shipped app, a background-processing plugin we depended on pinned a transitive Android dependency two minor versions behind what a second plugin required. Gradle resolved it silently — no crash on `flutter pub get`, no red flags in `flutter doctor`. It surfaced as a runtime `NoSuchMethodError` on physical devices only, three weeks after both plugins had already shipped together without incident. That’s the version of “dependency conflict” that doesn’t show up in a tutorial: nothing failed until a specific class was touched at runtime, on a specific Android API level, after a specific plugin update.

We had three options: downgrade the second plugin and lose the feature it existed for, carry a patch file against the first plugin forever, or fork it. We forked it. Here’s the reasoning, because “just fork it” is bad advice in isolation — most of the time patching is correct, and knowing which situation you’re in is the actual skill.

Patch first, fork only when the patch can’t stay small

Our default posture on any third-party plugin problem is `patch-package` (or its Flutter/Dart equivalent, hand-maintained diffs applied post-`pub get` via a build script). A patch is cheap: it’s a diff, it’s reviewable in a PR, and it disappears the moment upstream ships a real fix — you just delete the patch step and bump the version constraint. We carry two or three of these in active rotation across client codebases at any given time, and that’s the healthy state. Forking is what you do when the patch stops being small.

In this case, the patch wasn’t small because the fix wasn’t local. The plugin vendored a specific version of an Android dependency inside its own `build.gradle`, rather than declaring a version range and letting Gradle’s resolution strategy handle conflicts. Patching around that meant either:

  • Overriding the resolution strategy from the app-level `build.gradle` with a forced version — which worked, until the next plugin update silently re-vendored a different pin and broke it again with no compile-time warning.
  • Patching the plugin’s Gradle file directly to declare a range instead of a pin — which is a real code change to the plugin’s build logic, not a data diff, and needed re-verification against every plugin version bump.

The second option is where the line moves from “patch” to “fork” in practice. A patch that touches build configuration and needs semantic re-validation on every upstream release isn’t really a patch anymore — it’s an unversioned fork wearing a patch file’s clothes, minus the git history and minus the ability to diff cleanly against upstream when you need to.

Why upstream never took the fix

We filed the issue and PR upstream, because that’s the correct sequence even when you already know you’re forking — it costs an hour and sometimes it works. It didn’t here, for a reason worth naming: the maintainer’s CI matrix pinned exact dependency versions specifically to keep a separate downstream consumer’s build green, and our range-based fix would have widened that matrix in a way they weren’t resourced to re-test. That’s not a bad-faith maintainer or a dead project — it was a single-maintainer plugin with real users depending on the pinned behavior we needed to break. Our use case and their stability guarantee were structurally incompatible, not just momentarily out of sync.

This is the pattern to watch for when deciding fork-vs-patch-vs-wait: if the upstream maintainer has a stated reason not to take the fix — not just backlog, but an actual competing constraint — waiting for a merge is not a plan, it’s a hope. We gave it two weeks and moved to the fork once the maintainer explained the constraint in the issue thread.

What forking actually cost us

The honest accounting, because “we forked it” undersells the ongoing cost:

  • Pinned to a git URL, not a registry version. The `pubspec.yaml` dependency points at our fork’s commit hash, which means Dependabot-style update nudges stop working for that package entirely. We track upstream releases manually and cherry-pick.
  • We own the CI matrix for it now. Every Flutter or Android Gradle Plugin upgrade means testing our fork against it before touching the app, since we no longer inherit upstream’s testing.
  • Documentation debt. Anyone touching this codebase later needs to know why this one dependency is a git URL and not a version number — we left a comment in `pubspec.yaml` and a short note in the repo’s architecture doc, because this is exactly the kind of decision that looks like a mistake in a `git blame` five months later if the reasoning isn’t recorded next to it.

We took that cost because the alternative — a forced Gradle resolution override that could silently regress on any future plugin update — was a bug waiting for a specific set of circumstances to recur, and we’d already been burned by exactly that failure mode once.

The actual decision tree

Stripped of this specific case, the rule we apply is:

  1. Can the fix be expressed as a pure diff against source (not build config)? Patch it.
  2. Does the patch need re-validation on every upstream version bump because it touches build/dependency logic? That’s a fork, whether or not you call it one.
  3. Has upstream stated a concrete reason not to merge, versus just having backlog? Concrete reason means stop waiting.
  4. Once forking, pin to a commit hash, document the reason inline, and treat the fork’s CI as your responsibility going forward — not a one-time cost.

This is the kind of decision that doesn’t show up in a demo or a pitch — it shows up eight months later as either a stable app or a Slack thread with “runtime crash on Pixel devices only” in it. It’s the same category of judgment call we bring to code audits: not “does this pattern look wrong,” but “what specific failure mode is this trade-off protecting against, and did the team actually weigh it or just default to the easy answer.” If you’re staring at a dependency conflict in a shipped Flutter app and aren’t sure which side of this line you’re on, that’s a conversation worth having before you write the patch.