Scaling Riverpod across Monorepos: Sharing Providers in Multi-Package Flutter Projects

By Rasmus Holm · 29 August 20267,763 views
Scaling Riverpod across Monorepos: Sharing Providers in Multi-Package Flutter Projects

Introduction: The Monorepo Scaling Challenge

When we first architected our internal Flutter suite at our Aarhus office, the goal was simple: modularize the codebase into domain-specific packages to enforce boundaries and improve local development speed. With 150+ packages, our Flutter monorepo represents a significant engineering surface area. Integrating Riverpod across these boundaries introduces a specific challenge—state management is inherently global, but in a modularized world, that globality is often constrained by package boundaries. If you aren't careful, you end up with tight coupling that forces an entire package rebuild every time a single shared provider is tweaked.

In this article, we’ll explore how to effectively scale Riverpod across a multi-package environment, focusing on architecture, dependency management, and the crucial optimization of cache hit rates. If your build_runner process is taking minutes, or your Riverpod providers are creating circular dependency hell, you are likely missing the structural patterns required for high-velocity Flutter development.

The Anatomy of a Shared Provider Architecture

In a monorepo, a provider cannot just be a random file sitting in the root. We categorize our providers into three distinct layers to ensure that the task graph remains predictable for our build tools.

  1. Core Providers: These define the interface and data types (often using freezed or riverpod_annotation). These live in a core_foundation package that has zero dependencies on business logic.
  2. Feature Providers: These implement the specific logic. They depend on core_foundation but never on each other.
  3. Application Layer: This package ties everything together, often serving as the injection point for the DI container and the initial ProviderScope configuration.

By enforcing these layers, we prevent the "Big Ball of Mud" where a small change in a utility provider triggers a cascading rebuild of thirty downstream features. This is critical because Turborepo treats your pubspec.yaml files as nodes in a dependency graph. If feature_a depends on feature_b, changing feature_b invalidates the feature_a cache. We want to keep that graph as flat as possible.

Designing the Dependency Graph for Cache Efficiency

Turborepo succeeds when it understands exactly what needs to be rebuilt. When we use Riverpod, we rely heavily on code generation. The cost of running build_runner across 150 packages is prohibitive. To optimize, we structure our provider definitions to be as decoupled as possible.

We utilize an abstract interface for cross-package communication. If Feature A needs to trigger a state update in Feature B, it does not import Feature B directly. Instead, it interacts with an event bus or an abstract service provider defined in a shared library. This keeps the dependency graph narrow.

Step 1: Defining the Contract in a Library Package

First, isolate the state definitions. By keeping the provider interfaces in a separate library package, we ensure that only the consumers who strictly need the data model are triggered by its changes.

// package: core_api/lib/src/api_contract.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'api_contract.g.dart';

@riverpod
abstract class GlobalSession {
  String? get userId;
  void clearSession();
}

Step 2: Implementation via Dependency Injection

In the feature package, we provide the implementation. Crucially, we use the Provider pattern to expose this implementation without forcing a heavy dependency on the rest of the app.

// package: auth_feature/lib/src/auth_provider.dart
import 'package:core_api/core_api.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

@riverpod
class AuthManager extends _$AuthManager implements GlobalSession {
  @override
  String? build() => null;
  
  @override
  void clearSession() => state = null;
}

Optimizing CI/CD with Custom Cache Invalidation

In our monorepo, 65% of CI time reduction came from a cache invalidation plugin that understands our package dependency graph more precisely than the default. The default invalidates an entire subtree when any file changes; our plugin invalidates only the packages that actually import the changed module.

When working with Riverpod, the build.yaml file is the most sensitive part of the repository. If you change a generator setting, build_runner forces a re-scan. We wrote a custom Turborepo hash calculation that ignores build.yaml changes if the underlying source code remains identical. This prevents unnecessary rebuilds across the entire 150-package fleet.

Implementation of custom hash-keys for CI

We configure our turbo.json to be aware of the Riverpod code-gen outputs. By setting the outputs correctly, we ensure the cache is granular.

# turbo.json
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["lib/**/*.g.dart", "lib/**/*.freezed.dart"],
      "inputs": ["lib/**/*.dart", "pubspec.yaml"]
    }
  }
}

This setup ensures that if we are only modifying the UI layer in Feature C, and the lib/**/*.g.dart file remains unchanged in the core package, Turborepo will hit the cache and skip the rebuild entirely. This is the difference between a 10-minute CI build and a 45-second one.

Troubleshooting Common Pitfalls

  1. Circular Dependency Loops: When sharing providers, developers often import feature_a into feature_b and vice-versa. Use the dependency_validator package to enforce that your pubspec.yaml dependencies are only those explicitly used in the code.
  2. Provider Scope Conflicts: In large Flutter apps, avoid using global providers unless absolutely necessary. Use ProviderContainer overrides at the routing level. This makes testing much easier and prevents state leakage across test cases.
  3. Cache Pollution: If your local machine generates different code than your CI (often due to varying Flutter SDK versions), your cache will never hit. We enforce a strict fvm (Flutter Version Manager) policy across the monorepo to ensure binary compatibility.

Pro-Tips for Riverpod Architecture

  • Use riverpod_generator exclusively: Avoid Provider and StateNotifierProvider constructors that aren't annotated. The generator makes the task graph visible to the static analysis tools, which helps in calculating cache hashes.
  • Keep the Task Graph Flat: If you have more than 5 layers of deep dependencies, you are doing it wrong. Flatten your architecture by moving shared logic to a "foundation" layer rather than letting features build on top of features.
  • Remote Caching: Enable Turborepo Remote Caching on your CI. Since Flutter build artifacts are bulky, having the remote cache shared across the dev team in Aarhus prevents colleagues from rebuilding the same providers twice.

Conclusion: The Path to Sub-Minute Builds

Scaling Riverpod is not just about writing clean providers; it's about managing the build graph. By strictly separating your interface contracts from your implementation logic, and by being aggressive about granular caching in Turborepo, you can maintain a 150-package Flutter monorepo that feels as responsive as a single-package project.

The secret is realizing that the Flutter build toolchain is just another part of your dependency tree. When you treat your source code as a directed acyclic graph and optimize for the cache at every node, you stop fighting the tooling and start accelerating your development velocity. We’ve seen firsthand how these architectural constraints allow our team to push updates to multiple apps simultaneously without the fear of cascading CI failures.

Next time you find yourself adding a dependency, stop and ask: "Does this package really need to know about the entire state container, or does it just need an interface?" Answer that correctly, and your build times will thank you. With the right patterns, you don't have to sacrifice state management quality for build performance. You can have both, provided you take the time to map your dependencies and respect the cache boundaries inherent in every well-architected monorepo.

Comments

No comments yet. Be the first!

Sign in to leave a comment.