# Feature-First Clean Architecture in Production: Shipping Scooter's Coffee on Flutter

> The first official FFCA release is in the app stores. The three-phase playbook the migration followed, what the pattern looked like at 16 features and 88 packages, what we changed, and what it cost.

- Source: https://verygood.ventures/blog/feature-first-clean-architecture-in-production/
- Published: 2026-09-17
- Author: Rémy Baudet
- Tags: Flutter, Architecture, AI, Best Practices, Enterprise

---

In August I wrote about [Feature-First Clean Architecture](/blog/feature-first-clean-architecture/) and described its proving ground as a large native-to-Flutter migration. I kept the client anonymous because the app was not out yet. Now it is. The Flutter version of the [Scooter's Coffee app](/success-stories/scooters-coffee/) is live in both app stores, and it is the first production release built on FFCA end to end.

That makes this a good moment to write the post I could not write in August. Not the pattern on paper, but the pattern after a full migration, a team of engineers and coding agents working in it daily, and a release that real customers order coffee through. Some of it held up exactly as designed. Some of it we changed. And some of it costs more than the diagrams suggest.

## What we were migrating

Scooter's Coffee ran two mature native apps. iOS in Swift and SwiftUI, Android in Kotlin and Jetpack Compose. Both were revenue-critical. Both had their own teams and their own platform idioms. Both had to keep running while the Flutter app caught up to full feature parity. Ordering, payments, loyalty, gift cards, store locator, scan and pay. Nothing could regress and nobody could be asked to log in again.

[VGV joined the team](/our-services/application-development/) with a plan built around coding agents. Agents analyzed the native codebases, scaffolded the Flutter packages, and built features alongside the engineers, with humans reviewing at every gate.

Two decisions shaped how that went, and they turned out to be the same decision seen from two angles. The playbook the migration followed, and the architecture the code landed in.

## The migration playbook

The migration ran as three phases. A one-time analysis pass, a design system built before any feature, and a loop that repeats once per feature until the manifest is empty. That is the whole playbook, and the reason it fits FFCA so well is that the loop's unit of work and the architecture's unit of organization are the same thing. One feature, one set of packages, one pull request.

The tooling underneath is a mix. [Wingspan](/blog/vgv-wingspan-agentic-engineering-workflow/) and the [VGV Flutter plugin](https://github.com/VeryGoodOpenSource/very-good-claude-code-marketplace) are public and cover the workflow and the Dart and Flutter quality skills. The migration-specific skills for analysis, implementation, and gap analysis are part of [VGV Arcana](/resources/flutter-ai-engineering/), the enterprise tier of that stack, so they are not on the public marketplace. I describe them here by what they do rather than by command name, because the sequence is what transfers.

### Phase one: analyze once, then lock scope with the team

The first skill reads both native codebases, iOS and Android, and produces a migration manifest. Project metadata, module structure, every feature with its screens, models, and API calls, the dependency inventory, the platform APIs in use, the analytics events, the startup sequence, and a proposed migration order. Alongside the manifest it writes one brief per feature. Together they are the source of truth for what to build, and they are what the scaffolding step consumes later.

The manifest is a proposal, not a plan of record. We reviewed it with the Scooter's team, cut what was out of scope, corrected what the analysis got wrong, and locked the order.

The last step of this phase is landing the cross-cutting infrastructure before the first feature. Flavors, Firebase, analytics, the API clients, deep linking, localization, the native platform channels. In FFCA terms this is the `shared/` tree, and it has to exist before features can depend on it. Trying to catch up on infrastructure inside a feature PR is how a `cart_data` package ends up owning the HTTP client.

### Phase two: the design system before the features

Ship the kit first. Skip this and every feature forks its own buttons, and the visual parity work at the end doubles.

The theme came out first, colors, typography, spacing, radii, and elevation, extracted from Figma into `ThemeData` and `ThemeExtension` classes in one pass. Then a skill broke the design system pages into one ticket per component, each sized for a single PR, and another skill implemented each ticket against the Figma frame, with a Widgetbook use case so the component is catalogued and accessibility-checked as it lands. I wrote about that skill in detail in [Design Systems at AI Speed](/blog/figma-to-flutter-claude-code-skill-golden-tests/). In this repo the output is the `ui_kit` package under `shared/`, and the Widgetbook app in `apps/` exists to exercise it.

### Phase three: one feature, one PR

Everything after that is the same loop, run sixteen times.

1. **Spec or plan.** For anything beyond a trivial feature, a brainstorm and a plan through Wingspan, so the shape of the work is agreed before code exists.
2. **Implement the feature triple.** The implementation skill takes the feature name, its brief from the manifest, and the Figma URL for its screens. It scaffolds the domain package first and commits it, then fans out sub-agents in isolated git worktrees to build the data package, the presentation package, and the domain tests in parallel against that contract. This is the step FFCA makes possible. Three agents can work on one feature at the same time because the layers are separate packages with a shared domain between them. Always pass the Figma URL. Without it the skill guesses layouts, and it guesses confidently.
3. **Visual parity.** A whole-screen and per-component diff against the Figma frame, before anyone looks at behavior.
4. **Gap analysis.** A skill reconciles the migrated feature against the native apps, treating the native source as the source of truth, and reports what is missing, partial, or divergent.
5. **Architecture review.** Wingspan's review pass, which by this point knew the FFCA standard and caught Module, Handlers, and layer-boundary drift while the diff was still small.
6. **Verify by running it.** Tests pass, then someone opens the app and uses the feature. Tests prove the code does what the tests say. They do not prove the feature is right.
7. **Open the PR, file the follow-ups.** One PR per feature, one ticket per gap, each ticket stating the problem in plain language before the technical detail.

The loop is boring on purpose. Every feature went through the same seven steps, which meant every feature package looked the same, every PR was reviewable the same way, and every gap was recorded somewhere a human would see it.

## What the monorepo looked like at release

Here is the shape the loop produced:

```
features/
  auth/
    auth_domain/
    auth_data_firebase/       # identity provider
    auth_data_scooters/       # session against the Scooter's API
    auth_data_fake/           # for tests and widgetbook
    auth_presentation/
  cart/
    cart_domain/
    cart_data/
    cart_presentation/
  loyalty/
    loyalty_domain/
    loyalty_data/
    loyalty_data_fake/
    loyalty_presentation/
  user_profile/               # headless: no screens of its own
    user_profile_domain/
    user_profile_data/
  survey/                     # presentation-only: no data of its own
    survey_presentation/
  ...11 more features
shared/
  ui_kit/
  api_client/
  analytics/
  ...
apps/
  scooters_app/               # composes 16 features + shared
  widgetbook/
```

Sixteen features. Forty-eight feature packages. Eighty-eight packages in total once you count shared libraries and apps. Every one of them declares its dependencies in a pubspec, and every one of them is a real package edge the compiler checks.

Two variants showed up in practice and earned their place. `user_profile` is a headless feature, domain and data with no presentation, which the August post already described. It owns no screens. Other features reach its data through cross-feature Queries assembled at the composition root, never by reading its repository from a widget. `survey` went the other way, presentation only, because it renders a third-party survey with no domain of its own to model. That variant was not in the August post. It was added to the standard after this project showed the need for it. Neither required bending the layer rules.

## What held up

The properties I argued for in August are the ones that mattered most under load.

**Parallel work stayed parallel.** Features were migrated by different engineers, and often by different agents running at the same time, on disjoint sets of files. The domain contract went in first, then the data and presentation packages were built against it. Merge conflicts came from the app's routing table and composition root, which is exactly where two features are supposed to meet, and almost never from inside a feature.

**Every boundary crossing became a visible pubspec edit.** A presentation package cannot import a data package it has not declared, and the analyzer says so at the moment of writing rather than in a review two days later. What the compiler does not do is stop anyone, human or agent, from adding the dependency to the pubspec and then importing it legally. What it does is force that crossing into a pubspec diff, which is the one file reviewers always read. Over a migration with dozens of features, the boundary conversations happened in a handful of one-line diffs instead of buried imports, and most never happened at all, because the declared graph had already told the agent what was in scope.

**Agents landed code where it belonged.** The August post claimed that a coding agent needs a deterministic target for every new file. In practice this is what made the scaffolding pipeline trustworthy. The spec said "repository interface for orders," and there is exactly one directory in the repo where that can go. Across many runs and several model versions, the output landed in the same place. The FFCA standard lived in the repo as a document, and the agents' project instructions pointed at it by section name, so the structure was something they could read and not something they had to infer.

## What we changed

A pattern that survives production without changes was not tested very hard. Here is what moved.

### Handlers replaced loose callbacks

The August post showed navigation as typed callbacks injected into a feature's Module. The [standard](https://engineering.verygood.ventures/architecture/ffca/navigation/) still starts there for features with shallow trees. What did not survive contact with real features was applying it everywhere. A rewards screen with links to the menu, order history, an info sheet, and a promo code flow ends up with four callbacks threaded through every intermediate widget. Test setup has to provide all four even when the test touches none of them.

The fix, now the second half of the rule in the standard, is one navigation object per module for deep trees. The feature defines the class in its presentation package, the Module requires it and provides it to the subtree, and the widget that needs a callback reads it off the context at the call site. On this project we named them `{Feature}Handlers`.

```dart
/// Callbacks exposed by [RewardsModule]. Defined in rewards_presentation.
class RewardsHandlers {
  const RewardsHandlers({
    required this.onViewMenuPressed,
    required this.onViewHistoryPressed,
    this.onAddPromoCodePressed, // optional: the widget hides the button when null
  });

  final VoidCallback onViewMenuPressed;
  final VoidCallback onViewHistoryPressed;
  final VoidCallback? onAddPromoCodePressed;
}

class RewardsModule extends StatelessWidget {
  const RewardsModule({required this.handlers, super.key});

  final RewardsHandlers handlers;

  @override
  Widget build(BuildContext context) {
    return Provider<RewardsHandlers>.value(
      value: handlers,
      child: BlocProvider(
        create: (context) => RewardsCubit(
          loyaltyRepository: context.read(),
        )..load(),
        child: const RewardsScreen(),
      ),
    );
  }
}

// Deep in the tree, no constructor plumbing:
onPressed: () => context.read<RewardsHandlers>().onViewMenuPressed(),
```

The app still owns navigation. It builds the handlers object at the route boundary, pointing each callback at a typed route, and hands it to the Module. The feature still has no idea a router exists. The rule we settled on is simple. One callback can be a constructor parameter. Two or more, and it is a Handlers class.

### One domain, several data packages

Authentication turned out to need three data implementations. One against the identity provider, one against the Scooter's session API, and a fake for tests and the widget catalog. Loyalty needed a fake too. The August post allowed for one `{feature}_data` package per feature, and that was not enough.

The naming rule that came out of it is `{feature}_data_{backend}`. The backend goes in the suffix, so it reads `auth_data_firebase`, never `firebase_auth_data`, and the feature stays the primary sort key in every file browser and every agent's context. The package name, its barrel file, and its folder all share that name. A fake is just another backend, `auth_data_fake`, and it ships as a real package that the app or a test target depends on explicitly. No test-only code hides inside the production data package.

### Shared never depends on features

The line between `shared/` and `features/` is the one an agent is most likely to blur, and the one we ended up policing hardest. Two rules came out of it. A business capability belongs under `features/`, never `shared/`, even when several features use it. And a shared package must never depend on a feature package. When shared code needs a feature concept, say analytics needing the current user's id, the shared package declares a port, a small interface, and the app satisfies it with an adapter at the composition root.

This is the same dependency rule that already governs the layers inside a feature, applied one level up. Shared libraries sit below the feature graph. Nothing below can look up.

### Cross-feature reads live in Queries and Commands

Checkout needs the user's identity for a payments call. The cart needs product data from the menu. The August post covered the pattern in principle, a domain use case that injects both repositories, and production gave it a sharper edge. The rule became simple. Never pass another feature's id through a Module or a route as a parameter. Never read another feature's repository from a cubit or a widget. Never duplicate its model. Put the combination in a `Query` or `Command` in the domain of the feature that owns the result, cart for a cart populated with products, and let that domain depend on the other feature's domain. The use case itself is built at the app's composition root, where both repositories are available.

It also settled a common question. Identity and profile are two features, not one. `auth_domain` knows who is signed in. `user_profile_domain` knows everything else about them, and a `WatchCurrentUserProfileQuery` in the profile domain glues the two together. Adding a preference field to the profile never touches the auth feature again.

## What it costs

I want to be plain about this, because the diagrams make it look free.

**Eighty-eight pubspecs is a lot of pubspecs.** Every new dependency is declared in the package that uses it, which is the whole point, but it also means a version bump touches many files and a new feature is eight or nine files of boilerplate before the first line of Dart. [Dart workspaces](https://dart.dev/tools/pub/workspaces) make it resolvable in one pass. [Melos](https://melos.invertase.dev/) makes it scriptable. Neither is optional at this size. If your team is not ready to own that tooling, it will own you. The count is also the first thing people push back on, and I understand why. My answer is that the count is the boundary made visible. Every one of those files is a place where a dependency has to be declared on purpose and reviewed on purpose, and that is what buys the parallel work, the bounded context, and the reviewable graph described above. Fewer packages would mean fewer of those moments, not fewer dependencies.

**The composition root becomes real code.** FFCA pushes routing, dependency wiring, cross-feature use cases, and port adapters out of the features. All of it lands in the app package. At 16 features that is not a `main.dart` anymore. It needs its own structure, its own tests, and a clear owner, or it becomes the one place in the repo where the mud pit can still form.

**The rules only work if they are written down where the tools can read them.** The compiler enforces the package graph. The finer rules, naming, the shared-versus-feature line, the Handlers threshold, need a second layer. In August I described that layer as a custom analyzer run as an agent hook and again in CI. On this project it was a standards document inside the repo, referenced by section from the agents' project instructions, and it worked because every task pointed at it. Since then we have packaged the standard and that validation hook into an FFCA plugin for Claude Code, which blocks a pubspec edit that breaks a layer rule and puts the fix in the message. It is not public yet. Either way, a convention that lives in someone's head does not survive a fast migration with agents in the loop.

None of this changes my recommendation from August. For one app with a handful of features and no agents contributing, [Very Good Layered Architecture](/blog/very-good-flutter-architecture/) is still the simpler choice. FFCA earns its overhead when features need to be built in parallel, composed across apps, or handed to coding agents with any confidence.

## The takeaway

The reason I first organized code feature-first was ownership. A team should be able to own a feature end to end without stepping on anyone else. The Scooter's Coffee migration was the first time I watched that hold for a mixed team of engineers and agents, at production scale, from the first scaffolded package to the store listing.

Some of the pattern changed on the way to production. Handlers replaced loose callbacks. Data packages grew backend suffixes. The shared-versus-feature line got a rule with teeth. Every one of those changes came from a specific moment where the original version was not enough, and every one of them is now part of the [FFCA standard](https://engineering.verygood.ventures/architecture/ffca/overview/).

If you want to see the pattern without a client's codebase in the way, [Mealify](https://github.com/VGVentures/mealify_feature_first) is a complete working example to clone. And if you are planning a native-to-Flutter migration of your own, this is the work we do. [Talk to us](/contact-us/) and we will walk through how the playbook and the architecture would apply to your codebase.

## Frequently Asked Questions

### What is the first production app built with Feature-First Clean Architecture?

The Scooter's Coffee mobile app on iOS and Android. VGV joined the Scooter's Coffee team to migrate two native apps, Swift and SwiftUI on iOS and Kotlin and Jetpack Compose on Android, into a single Flutter codebase organized with FFCA. The Flutter app ships from one monorepo of 16 feature packages plus shared libraries.

### How does FFCA change the cost of a native-to-Flutter migration?

A migration is already feature-by-feature work. FFCA gives each migrated feature its own domain, data, and presentation packages, so the analysis of the native apps maps directly onto a package scaffold. Features can be migrated in parallel by different engineers and coding agents without touching each other's files, and the package graph turns every cross-feature shortcut into a visible pubspec edit instead of a buried import.

### What is the VGV native-to-Flutter migration playbook?

Three phases. First, a one-time analysis of the native iOS and Android codebases that produces a migration manifest and one brief per feature, reviewed with the client team to lock scope and order, followed by landing cross-cutting infrastructure such as flavors, analytics, and API clients. Second, the design system, with the theme extracted once and each component shipped in its own PR with a Widgetbook use case, before any feature is built. Third, a loop that runs once per feature: plan, implement the domain, data, and presentation packages, check visual parity against Figma, run a gap analysis against the native apps, review the architecture, verify by running the app, and open one PR per feature with follow-up tickets for every gap.

### What did VGV change about FFCA after using it in production?

Four things. Modules with many navigation callbacks now take a single <code>{Feature}Handlers</code> object instead of loose constructor parameters. Data packages that implement the same domain against different backends carry a backend suffix, such as <code>auth_data_firebase</code>, and fake implementations ship as their own packages. Shared packages never depend on feature packages, and when shared code needs a feature concept it declares a port that the app satisfies with an adapter. Cross-feature reads and writes live in domain Queries and Commands that are assembled at the app's composition root.

### What does FFCA cost compared to a layered Flutter app?

More packages and more pubspec files. The Scooter's Coffee monorepo has 88 packages, which means workspace tooling like Dart workspaces and Melos is mandatory, and the app's composition root grows into a real piece of code that needs its own structure. For a single small app with no cross-app reuse and no coding agents in the loop, VGV's layered architecture remains the simpler choice.
