# Feature-First Clean Architecture: The AI-Ready Monorepo Pattern for Flutter

> Feature-first packaging, clean architecture within each feature, and why it matters more now that coding agents contribute

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

---

I have been organizing Flutter code feature-first for years. I named the approach *Feature-First Clean Architecture*, or FFCA. The motivation was ownership. I wanted each feature team to own what they build end to end, without stepping on another team's code. Clean architecture did the rest, keeping a clear separation of concerns and responsibilities between the layers inside each feature.

When I joined VGV, I saw a chance to take FFCA further. I proposed it based on that past experience, and the team's Flutter experts helped sharpen it. [Brian Egan](/blog/authors/brian-egan/) contributed several solid improvements. The proving ground was a large native-to-Flutter migration. Multiple apps, dozens of features, one monorepo, and a mandate to move fast without the code turning into a mud pit.

Two things shaped the result. The scale of a shared monorepo, that's not so new, and the arrival of [coding agents](/blog/vgv-wingspan-agentic-engineering-workflow/) as real contributors, and that was very new! The way you organize the code is also the interface your tools read. A structure only humans can navigate leaves your agents guessing on every task.

FFCA keeps what [clean architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) already does well and changes one thing. The feature, not the layer, becomes the top-level unit of organization. Feature organization and package structure in the monorepo, as well as clean architecture's dependency rule, are not only guidelines you enforce in review. They also become rules the compiler enforces for you. The complete standard, with the full package and naming conventions, is documented on [Very Good Engineering](https://engineering.verygood.ventures/architecture/ffca/overview/).

## Why layer-first organization stops scaling in a Flutter monorepo

Most Flutter codebases organize by layer, including VGV's own [Very Good Layered Architecture](/blog/very-good-flutter-architecture/). For a single app with a handful of features, that is the right call, and I still reach for it.

![Very Good Layered Architecture with three layers: an application layer holding each feature's page and Bloc, a domain layer of repositories, and a data layer of data sources](/assets/images/blog/feature-first-clean-architecture/legacy-layered.png)

In a layer-organized codebase, packages group by responsibility, not by feature. A feature's UI and logic sit in the application layer, its repository in the domain layer, and its data source in the data layer. That works well when there are three features. At ten or more, any single feature is spread across all three layers, and nothing in the structure ties its pieces together except a naming convention you hope everyone follows.

![The Cart feature highlighted across the application, domain, and data layers, showing that moving or reusing it means touching all three layers](/assets/images/blog/feature-first-clean-architecture/layered-scatter.png)

Three costs compound from there.

Feature boundaries stay soft. Nothing physically stops the cart's presentation code from reaching into product internals, so over time it does. The boundary lives in a reviewer's head, not in the build.

Isolating or reusing a whole feature is harder than it should be. When you want to pull `Cart` into a second app, or remove it from one, there is no single thing to move. You go hunting through every layer package for the pieces that belong to it.

And then there are the agents. A coding agent working in a layer-organized repo has no machine-legible map of where a feature begins and ends. Feature folders do not change that. To the tooling it is all one package, so any file can import any other, and there is no boundary for the agent to respect. So it does what it has to. It guesses where new code belongs. It reasons over the whole repo to find what is relevant, because relevance is not encoded anywhere it can read. And it crosses layer boundaries in ways that only surface in review, because nothing in the structure told it the boundary was there. 

## The shift: feature-first packaging with enforced layers

FFCA inverts the top level. The primary unit of organization is the feature, and each feature carries its own layers as separate packages.

```
features/
  cart/
    cart_domain/         # Dart: models, repository interfaces, use cases
    cart_data/           # Dart: data sources, DTOs, mappers, repo impls
    cart_presentation/   # Flutter: screens, blocs, the feature's Module
  product/
    product_domain/
    product_data/
    product_presentation/
  auth/                  # Headless feature: no screens, so no presentation
    auth_domain/
    auth_data/
apps/
  mobile_app/            # Composes features + shared into a deployable app
  admin_app/
shared/
  ui_kit/                # Brand-agnostic widgets
  api_client/
```

![Diagram of a feature-first Flutter monorepo: apps compose feature and shared packages, and each feature owns its domain, data, and presentation layer packages](/assets/images/blog/feature-first-clean-architecture/monorepo-structure.png)

Clean architecture's rules still hold inside each feature. The domain layer is pure Dart and depends on nothing else in the feature. The data layer depends on the domain to implement its repository interfaces. The presentation layer depends on the domain to assemble screens, and never on the data layer. A feature with no screens, like `auth` or `analytics`, is a headless feature: domain plus data, and it can grow a presentation package later with no structural change.

![Diagram of a single feature's three layers as separate packages, with the presentation and data layers both depending on the domain layer](/assets/images/blog/feature-first-clean-architecture/feature-layers.png)

What changed is not the layering. A feature is now a thing you can point at, move, reuse, and reason about as a unit. And because each layer is a real package with a declared dependency graph, the boundaries are no longer just conventions. They are artifacts the build can check.

This is more than feature folders. A feature-based layout collocates a feature's files but keeps the layers as conventions inside a single package. FFCA makes each layer its own package, wired together through [Dart workspaces](https://dart.dev/tools/pub/workspaces), so the dependency graph enforces the boundary rather than a reviewer.

Two things fall out of this structure. The first is that an app becomes little more than the list of features it composes. One app can ship a feature that another leaves out, and neither app touches the other's code to make that happen. Because each feature declares its own dependencies, an app pulls in only the code and SDKs the features it ships actually use, nothing more.

The second is deferred loading. Each feature is its own package, and a feature's presentation layer gives each screen its own barrel file, so an app can defer-load a whole feature, or a single screen, on demand. On the web that splits one large bundle into smaller chunks loaded as they are needed, and on Android it maps onto dynamic feature modules. This is one of the improvements Brian brought to FFCA, and it stays a real win for large web apps. Flutter's [deferred components](https://docs.flutter.dev/perf/deferred-components) cover the mechanics.

## Why it pays off, especially with agents

The properties that make FFCA good for a large human team are the same properties that make it good for coding agents. The predictability that helps a new engineer find their way is the same predictability an agent depends on.

**Deterministic targets.** Every artifact has exactly one legal home, decided by naming conventions and layer packages. A repository interface goes in `{feature}_domain/lib/repositories/`. Its implementation goes in `{feature}_data/lib/repositories/`. A screen's bloc goes in `{feature}_presentation/lib/{screen}/bloc/`. There is no judgment call, so the output is reproducible across runs, across models, and across prompts. Ask two different agents where a new mapper belongs and they land in the same directory, because the structure only permits one answer.

**Bounded context.** The pubspec dependency graph scopes what is relevant. When work happens in `cart_presentation`, the only packages that resolve are that package plus its declared domain dependencies. An agent does not have to read the whole repo to understand the task, because the workspace has already told it what is in scope. The dependency declaration is the context window.

```yaml
# cart_presentation/pubspec.yaml
dependencies:
  cart_domain:
    path: ../cart_domain
  product_domain: # cart shows product info, so it depends on product's domain
    path: ../../product/product_domain
  # note: no cart_data, no product_data. Presentation never sees a data layer.
```

**Mechanical enforcement.** A layer violation is a compile error, not a review comment. If `cart_presentation` tries to import `cart_data`, the import fails, because that dependency is not declared and never should be. The bad cross-layer reference is blocked at the moment it is written, which means an agent gets immediate, unambiguous feedback and self-corrects instead of shipping something a human has to flag later. We back the finer-grained rules with a custom analyzer that checks naming, cross-feature data leaks, and dependency cycles as an agent hook while you code and again in CI. It is a convenience, not a requirement. The compile-time boundary already comes for free with the package structure.

**Parallel work without collisions.** Splitting a feature into separate domain, data, and presentation packages means several contributors can build different layers of the same feature on disjoint sets of files. One agent implements the repository in the data package while another builds the screen in the presentation package, both against the same domain contract, without touching each other's files. The same isolation that lets two engineers avoid a merge conflict lets two agents run in parallel.

## Routing without coupling: callback injection

Feature isolation raises an obvious question. If a feature cannot import another feature's code, how does the cart navigate to a product screen?

The answer is that features never know about the router. Navigation is a dependency the app layer injects. The feature declares *what* moments need navigation. The app decides *where* they go.

A feature exposes typed callbacks at its entry point, the Module:

```dart
// In cart_presentation. The feature knows a product was tapped.
// It does not know, or care, what happens next.
class CartListModule extends StatelessWidget {
  const CartListModule({
    required this.cartsRepository,
    required this.onProductSelected,
    super.key,
  });

  final ICartsRepository cartsRepository;
  final void Function(String productId) onProductSelected;
  // ...
}
```

The app layer wires those callbacks to real, type-safe routes. We use [`go_router`](https://pub.dev/packages/go_router) with [`go_router_builder`](https://pub.dev/packages/go_router_builder), so navigation goes through generated route classes, never string paths:

```dart
// In the app layer. This is where cart and product are allowed to meet.
@TypedGoRoute<CartListRoute>(path: '/cart')
class CartListRoute extends GoRouteData {
  @override
  Widget build(BuildContext context, GoRouterState state) {
    return CartListModule(
      cartsRepository: context.read(),
      onProductSelected: (id) => ProductDetailRoute(id: id).go(context),
    );
  }
}
```

The cart package has no dependency on the product package's routes, or on the router at all. It stays reusable and testable in isolation, and the one place two features are wired together is explicit and lives in the app. This is also why an FFCA feature drops cleanly into an [add-to-app](https://docs.flutter.dev/add-to-app) scenario. The same Module runs as a standalone entry point, with the callbacks pointed at a platform channel instead of a route.

## The takeaway

I built the first version of this to solve a human problem. I wanted a team to own a feature outright, without reaching into code that was not theirs. What I did not expect is that a coding agent needs the same thing for the same reason. It has to know where a feature starts, where it ends, and what it is allowed to depend on, and it has to learn that from the structure alone, because the structure is all it can read.

FFCA gives it exactly that. Each feature has one home, the dependency graph draws the lines, and the compiler holds them. None of it is exotic. You get package boundaries, naming you can predict, and a check that runs in CI. The reward is that your architecture is not just something you explain in review, it becomes something the build can prove, for the people on your team and the agents working next to them.

You do not have to convert a whole repo to feel it. Pick one feature, split it into its domain, data, and presentation packages, and point an agent at it. A full working codebase, [Mealify](https://github.com/VGVentures/mealify_feature_first), is there to clone and explore. The first time it drops new code exactly where it belongs, without being told, you will not want to go back!
