better_effect_analyzer

pub package Dart SDK

Static analysis and whole-project dependency-graph validation for better_effect and better_effect_flutter.

This package has two complementary layers:

  1. an official Dart Analysis Server plugin for fast diagnostics in the IDE, dart analyze, and flutter analyze;
  2. a project-wide graph checker and CLI for validating complete Module roots across multiple Dart libraries.

The plugin catches local mistakes while you edit. The graph checker answers a different question: whether the services requested throughout the project are actually provided by the application Module, and whether those providers form a valid dependency graph.

The analyzer package is tooling only. It is not a runtime dependency of better_effect or better_effect_flutter.

What it checks

The plugin registers five correctness warnings by default:

  • discarded lazy Effects;
  • ignored EffectContext operations;
  • constructor-backed Bindings without an informative service type;
  • incompatible Binding implementations;
  • duplicate service identities declared directly in a Module.

It also registers four optional Flutter MVVM architecture lints:

  • repositories requesting other repositories;
  • ViewModels requesting low-level services;
  • Widgets resolving business dependencies directly;
  • ViewModels registered as application-lifetime singletons.

The graph CLI adds whole-project checks for:

  • missing services in a root Module;
  • dependency cycles;
  • recursive Module composition;
  • explicitly requested Modules that do not exist;
  • duplicate or incompatible providers after Module composition;
  • resources that require another resource declared later during startup.

Requirements and compatibility

  • Dart SDK 3.10 or newer;
  • Flutter 3.38 or newer when analyzing a Flutter application;
  • better_effect 0.4.x for the symbols being checked;
  • analyzer 12.x tooling.

Version 0.4.0 continues to target the analyzer line used by Flutter SDKs that pin meta to 1.18.0:

dependencies:
  analysis_server_plugin: '>=0.3.14 <0.3.18'
  analyzer: ^12.1.0

The upper bound on analysis_server_plugin is deliberate: later plugin releases move to newer analyzer lines and may conflict with a Flutter SDK's pinned dependencies. Check your Flutter SDK's analyzer version before changing these constraints.

Installation

Install the package when you want to run the project-wide graph CLI:

dart pub add --dev better_effect_analyzer

Or add it manually:

dev_dependencies:
  better_effect_analyzer: ^0.4.0

The Analysis Server plugin is configured from the project's top-level analysis_options.yaml. It does not need to be imported by application code or added as a runtime dependency.

Enable the Analysis Server plugin

Add the plugin to the application's top-level analysis_options.yaml:

include: package:flutter_lints/flutter.yaml

plugins:
  better_effect_analyzer:
    version: ^0.4.0
    diagnostics:
      repository_requests_repository: true
      viewmodel_requests_service: true
      widget_requests_business_dependency: true
      singleton_viewmodel: true

The five correctness warnings are enabled whenever the plugin is enabled. The four architecture rules above are opt-in and can be turned on independently.

Restart the Dart Analysis Server after changing the plugins section. The plugin applies to both Dart and Flutter projects:

dart analyze
flutter analyze

Local monorepo development

When the analyzer package is checked out beside an application, use an absolute path while developing the plugin:

plugins:
  better_effect_analyzer:
    path: /absolute/path/to/workspace/packages/better_effect_analyzer
    diagnostics:
      repository_requests_repository: true
      viewmodel_requests_service: true
      widget_requests_business_dependency: true
      singleton_viewmodel: true

The plugin resolver expects the local path to be absolute. Published projects should use version instead.

Plugin-only versus CLI usage

Use only analysis_options.yaml when you want IDE and analyzer diagnostics. Add better_effect_analyzer to dev_dependencies when you also want to run dart run better_effect_analyzer from the application root:

dev_dependencies:
  better_effect_analyzer: ^0.4.0

The analyzer package should not be added to dependencies and should never be imported by production Flutter code.

Local plugin diagnostics

discarded_effect

Effects are lazy. Constructing an Effect as a standalone expression does not execute it:

repository.save(user);
// Warning: the Effect is created but never executed.

Compose or run it explicitly:

await use.unwrap(repository.save(user));
return repository.save(user);
await runtime.run(repository.save(user));

unawaited_effect_context_operation

use.unwrap, use.result, use.tryAsync, and use.acquire return Futures. Ignoring one loses its value and its typed failure propagation:

use.unwrap(repository.save(user));
// Warning: await or return this operation.

Use await or return the operation from the current Effect body:

await use.unwrap(repository.save(user));
return use.unwrap(repository.save(user));

missing_binding_type_argument

A constructor tear-off is typed as a Function. Without a service type, Dart can infer Object and register the wrong contract:

Module([
  .provide(DatabaseLive.new),
  // Warning: add the service type argument.
]);

Declare the contract explicitly:

Module([
  .provide<Database>(DatabaseLive.new),
]);

A typed ServiceKey can also provide the inference constraint:

.provide(DatabaseLive.new, key: primaryDatabase)

incompatible_provider

The implementation, instance, or resource acquired by a Binding must satisfy the registered service contract:

Module([
  .provide<UserRepository>(AnalyticsService.new),
  // Warning: AnalyticsService is not a UserRepository.
]);

The rule understands regular method calls and Dart dot shorthand.

duplicate_service_binding

A Module cannot contain two registrations with the same service type and key:

final module = Module([
  .provide<Database>(SqliteDatabase.new),
  .provide<Database>(MemoryDatabase.new),
  // Warning: duplicate unnamed Database binding.
]);

Different ServiceKey values are different identities:

Module([
  .provide<Database>(PrimaryDatabase.new, key: primaryDatabase),
  .provide<Database>(AnalyticsDatabase.new, key: analyticsDatabase),
]);

Optional Flutter MVVM architecture lints

These rules are opt-in because teams use different boundaries. They implement the architecture direction used by better_effect_flutter:

Widget → ViewModel → Repository/UseCase → Service

repository_requests_repository

Repositories should not coordinate other repositories directly:

final class BookingRepositoryLive implements BookingRepository {
  AppEffect<Booking> create() => Effect.result((use) async {
    final users = use<UserRepository>();
    // Warning: move cross-repository composition to a UseCase or ViewModel.
    return loadBooking(users);
  });
}

viewmodel_requests_service

ViewModels should depend on repositories or use cases rather than low-level infrastructure:

final class HomeViewModel extends EffectViewModel {
  AppEffect<HomeData> load() => Effect.result((use) async {
    final api = use<HomeApiClient>();
    // Warning: expose this operation through a Repository or UseCase.
    return api.loadHome();
  });
}

The rule recognizes common names such as Service, Client, Api, Database, DataSource, and Storage, as well as conventional data/services, data/sources, and infrastructure paths.

widget_requests_business_dependency

Widgets should communicate with ViewModels instead of resolving repositories, services, or use cases directly:

final class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final repository = context.readEffectService<UserRepository>();
    // Warning: expose the dependency through the ViewModel.
    return HomeBody(repository: repository);
  }
}

readEffectService remains valid in route and Provider factories. The lint reports direct business reads inside Widget and State classes.

singleton_viewmodel

ViewModels normally follow the lifecycle of their View or feature rather than the entire application:

Module([
  .singleton<HomeViewModel>(HomeViewModel.new),
  // Warning: create this ViewModel at a View or feature boundary.
]);

The rule also recognizes the default lazy-singleton behavior of provide:

.provide<HomeViewModel>(HomeViewModel.new)

.provide<HomeViewModel>(
  HomeViewModel.new,
  lifetime: .singleton,
)

Suppress or disable a plugin diagnostic

Use normal Dart suppression syntax for an isolated exception:

// ignore: better_effect_analyzer/discarded_effect
repository.save(user);

Or suppress a rule for a file:

// ignore_for_file: better_effect_analyzer/repository_requests_repository

For a project-wide architecture decision, leave the rule out of the diagnostics map instead of suppressing every occurrence.

Whole-project Module graph checking

A normal analyzer rule visits one resolved library at a time. The graph checker builds an AnalysisContextCollection for the project and indexes classes, service requests, constructor tear-offs, resource acquisition callbacks, and Module declarations across the analyzed files.

By default it analyzes lib. Pass --include-tests to include test as well. Generated files ending in these suffixes are skipped by default:

  • .g.dart;
  • .freezed.dart;
  • .mocks.dart;
  • .gr.dart;
  • .route.dart.

Run from the application root

If the CLI is in the application's dev dependencies:

dart run better_effect_analyzer

Pass an explicit project path when the current directory is not the application root:

dart run better_effect_analyzer ../my_app

Select root Modules

When no --module option is supplied, Modules not included by another Module are treated as roots:

dart run better_effect_analyzer --module appModule

Repeat the option to validate more than one named root:

dart run better_effect_analyzer \
  --module appModule \
  --module backgroundModule

This is useful when a project intentionally has several independent applications, isolates, or feature roots.

Include tests

dart run better_effect_analyzer --include-tests

Use this when test-only Modules or fixtures should participate in graph validation.

Output formats and exit codes

Human-readable output is the default:

dart run better_effect_analyzer
# error   lib/config/app_module.dart:12:3 [missing_service] ...

Machine output is one diagnostic per line:

dart run better_effect_analyzer --format machine
# lib/config/app_module.dart:12:3:error:missing_service:...

JSON output is suitable for CI annotations or custom tooling:

dart run better_effect_analyzer --format json

The executable exits non-zero when it finds errors. The --fatal-warnings flag is enabled by default; use --no-fatal-warnings when warnings should not fail the command.

Graph diagnostics

missing_service

The checker combines service requirements from:

  • non-nullable constructor parameters used by AutoInjector;
  • use<T>() and EffectContext.service<T>();
  • callable services<T>() and services.get<T>();
  • static Effect.service<T>(), including dot shorthand;
  • resource(acquire: ...) callbacks;
  • Module spreads, Module.merge, and overrideWith.

Example:

error   lib/config/app_module.dart:12:3 [missing_service]
Provider 'UserRepository' requires 'Database', but Module 'appModule' doesn't provide it.

resource_dependency_declared_after_provider

This reports a direct or transitive startup dependency on a resource that is declared later in the flattened Module. Move the dependency earlier or reorder the owning resource so acquisition order matches the dependency graph.

dependency_cycle

The checker reports cycles across constructor and contextual dependencies:

Database → SessionRepository → Database

module_composition_cycle

This reports recursive composition such as a Module that eventually includes itself through Module.merge, spreads, or overrideWith.

module_not_found

This reports a name passed to --module when no matching Module declaration is found in the analyzed project.

Provider diagnostics in the graph

The graph also reports duplicate_service_binding and incompatible_provider after all included Modules and overrides are flattened. The IDE versions of these rules remain useful for immediate local feedback.

Static-analysis boundary

The checker follows statically visible declarations, list spreads, Module composition, constructor tear-offs, and contextual requests. It cannot reconstruct dependency lists assembled through reflection, runtime-generated code, or complex dynamic control flow. Prefer declarative Modules when graph validation is part of CI.

Embed the graph checker

The public library exposes the graph model for custom tooling:

import 'package:better_effect_analyzer/better_effect_analyzer.dart';

final result = await BetterEffectGraphChecker('/path/to/app').check(
  options: const GraphCheckOptions(
    includeTests: true,
    moduleNames: {'appModule'},
  ),
);

for (final diagnostic in result.diagnostics) {
  print(diagnostic.toMachine());
}

if (result.hasErrors) {
  throw StateError('The better_effect graph is invalid.');
}

Available result helpers:

  • GraphDiagnostic.toJson and GraphCheckResult.toJson;
  • GraphDiagnostic.toMachine;
  • GraphCheckResult.hasErrors and hasWarnings;
  • sorted, immutable diagnostics.

Use excludedSuffixes in GraphCheckOptions when a generator uses additional file suffixes:

const options = GraphCheckOptions(
  excludedSuffixes: {'.generated.dart', '.g.dart'},
);

CI recipe

A Flutter project can run both layers:

flutter analyze --fatal-infos
dart run better_effect_analyzer --format machine
flutter test

A Dart-only project can replace the first and last commands with dart analyze --fatal-infos and dart test.

Development and API reference

From this package directory:

dart pub get
dart analyze --fatal-infos
dart test
dart run bin/better_effect_analyzer.dart --help
./tool/check.sh

The public import is:

import 'package:better_effect_analyzer/better_effect_analyzer.dart';