Clean Architecture Linter

pub package License: MIT

πŸ‡°πŸ‡· ν•œκ΅­μ–΄ README | πŸ‡ΊπŸ‡Έ English README

A comprehensive custom lint package that automatically enforces Clean Architecture principles in Flutter/Dart projects with Riverpod state management. Write code naturally while the linter guides you toward perfect Clean Architecture compliance with real-time feedback and actionable corrections.

⚠️ Note: This package is designed for projects using Riverpod for state management. Some presentation layer rules specifically validate Riverpod patterns.

✨ Key Features

  • πŸ›‘οΈ Automatic Clean Architecture Protection - Write code freely, linter catches violations
  • 🎯 34 Specialized Rules - Comprehensive coverage of all Clean Architecture layers
  • πŸš€ Flutter-Optimized - Built specifically for Flutter development patterns
  • 🎨 Riverpod State Management - Enforces 3-tier provider architecture (Entity β†’ UI β†’ Computed)
  • πŸ“š Educational - Learn Clean Architecture through guided corrections
  • ⚑ Real-time Feedback - Immediate warnings with actionable solutions
  • πŸ”§ Zero Configuration - Works out of the box with sensible defaults
  • πŸ§ͺ Test-Aware - Smart exceptions for test files and development contexts

πŸ“‹ Rules Overview (34 Rules)

🌐 Core Clean Architecture Principles (6 rules)

  1. Layer Dependency - Enforces dependency direction (inward only)
  2. Domain Purity - Prevents external framework dependencies in domain layer
  3. Dependency Inversion - Validates abstraction-based dependencies
  4. Repository Interface - Ensures proper repository abstractions
  5. Circular Dependency - Prevents circular dependencies between layers
  6. Boundary Crossing - Validates proper layer boundary crossing

🎯 Domain Layer Rules (2 rules)

  1. UseCase No Result Return - UseCases should return entities directly (pass-through pattern)
  2. Exception Naming Convention - Feature prefix for domain exceptions

πŸ’Ύ Data Layer Rules (10 rules)

  1. Model Structure - Freezed models with entity composition
  2. Model Field Duplication - No duplicate entity fields in models
  3. Model Conversion Methods - Required toEntity() method in extensions
  4. Model Naming Convention - Models must end with Model suffix
  5. DataSource Abstraction - Abstract interfaces for data sources
  6. DataSource No Result Return - DataSources throw exceptions
  7. Repository Implementation - RepositoryImpl must implement domain interface
  8. Repository Pass Through - Repositories return Future<Entity> (warns on Result pattern)
  9. Repository No Throw - Repositories use pass-through pattern (AppException types allowed)
  10. DataSource Exception Types - Use defined data layer exceptions only
  11. Model Entity Direct Access - Use .toEntity() instead of direct .entity access

🎨 Presentation Layer Rules (14 rules)

  1. No Presentation Models - Use Freezed State instead of ViewModels
  2. Extension Location - Extensions in same file as the class
  3. Freezed Usage - Use Freezed instead of Equatable
  4. Riverpod Generator - Use @riverpod annotation
  5. Presentation No Data Exceptions - Use domain exceptions only
  6. Presentation Use AsyncValue - Use AsyncValue for error handling (3-tier architecture)
  7. Presentation No Throw - No exception throwing in Presentation layer
  8. Widget No UseCase Call - Widgets should not call UseCases directly (use Providers)
  9. Widget Ref Read Then When - Avoid using .when() after ref.read() (anti-pattern)
  10. Riverpod Ref Usage - Use ref.watch() in build(), ref.read() in methods (with UseCase detection)
  11. Riverpod Ref After Async Gap - Advisory warning for ref.read/watch/listen/invalidate/refresh after await in provider methods (a preceding if (!ref.mounted) return; guard suppresses it)
  12. Riverpod Provider Naming - Provider functions must include type suffix (repository/usecase/datasource)
  13. Ref Mounted Usage - Avoid ref.mounted in the UI layer (widgets/pages); inside a Notifier it is the recommended disposal guard and is not reported
  14. Riverpod Keep Alive - Only use keepAlive: true for app-wide or app-lifetime state (auth, settings, cache, startup, listener, ...). Name/path heuristic; full keyword list and the // ignore + reason convention for generic-noun caches in doc/EXAMPLES.md Β§9

πŸ”§ Cross-Layer Rules (1 rule)

  1. Allowed Instance Variables - Enforces stateless architecture (UseCase/Repository/DataSource)

πŸ”• Opt-in: Riverpod State After Async Gap

Riverpod State After Async Gap (riverpod_state_after_async_gap) - Reports an unguarded state = … write or state read after an await in provider methods, including state = await …, same-statement reads that run after an await (await foo() ?? state), and reads in bodies that run after a control-flow await (await for, if (await …), a pattern when guard that awaits). Receiver-first forms (state.foo(await x)) and locals/parameters named state are not reported; this.state and super.state still are (reads and writes alike). Riverpod 3 throws UnmountedRefException from both the state setter and getter once the provider is disposed, so the guard belongs right after the await: await …; if (!ref.mounted) return; (or final next = await …; if (!ref.mounted) return; state = next;). Disabled by default because the state = await idiom is common in existing apps; enable it per project:

# analysis_options.yaml
plugins:
  clean_architecture_linter:
    diagnostics:
      riverpod_state_after_async_gap: true

πŸ§ͺ Optional: Test Coverage Rule

Test Coverage - Enforces test files for UseCases, Repositories, DataSources, and Notifiers (disabled by default)

πŸ“– Implementation Guide: See CLEAN_ARCHITECTURE_GUIDE.md for detailed patterns and examples.

🎨 Riverpod State Management: See CLAUDE.md § Riverpod State Management Patterns for 3-tier provider architecture guide.

πŸš€ Quick Start

πŸš€ v2.0: Starting with 2.0.0-dev.1, this package runs on the official analysis_server_plugin β€” no custom_lint dependency, no pubspec_overrides.yaml workaround. Lint runs directly via dart analyze (use dart analyze, not flutter analyze β€” see the warning below). Upgrading from a v1 (custom_lint) setup? Follow MIGRATION.md.

πŸ“‹ Requirements

  • Dart SDK: 3.11.0+ (analyzer 14.3.0); 3.13.0+ when riverpod_lint is enabled alongside
  • Flutter: 3.0+ (optional, for Flutter projects)
  • Riverpod: Required for presentation layer rules (riverpod_generator recommended)

1. Enable the plugin

# analysis_options.yaml
plugins:
  clean_architecture_linter: ^2.0.0-dev.1

analyzer:
  exclude:
    - test/**
    - "**/*.test.dart"    # Exclude test files
    - "**/*.g.dart"       # Exclude generated files
    - "**/*.freezed.dart" # Exclude Freezed files
    - "**/*.mocks.dart"   # Exclude mock files

Do not also add clean_architecture_linter to dev_dependencies when your project uses analyzer-bound tools such as riverpod_lint. The ASP plugin is resolved in its own synthetic package from the plugins: section, which avoids forcing its analyzer constraints into your app's pub solve.

2. Run the linter

dart pub get
dart analyze        # Flutter projects too β€” use `dart analyze`, NOT `flutter analyze`

Locally, dart analyze reports the 34 default rules in its output (plus the opt-in riverpod_state_after_async_gap, see the rule list above). In CI, a single No issues found! is not proof the plugin answered β€” use the sentinel gate in the warning below.

⚠️ Use dart analyze, not flutter analyze. flutter analyze silently drops analysis_server_plugin diagnostics (it stops collecting before the plugin publishes its results) and prints No issues found! even when violations exist. This applies to CI and local runs alike. Root cause and a regression guard: docs/analysis/FLUTTER_ANALYZE_PLUGIN_LOSS.md.

dart analyze (Dart 3.13.2) has a milder form of the same race: it can return before the plugin isolate publishes, so a single No issues found! is not proof. For CI, gate on a sentinel violation that must always be reported: tools/lint_sentinel/README.md.

3. See it in action

example/ is a runnable Dart project with a good_examples/ (0 warnings) and a bad_examples/ folder (2 intentional violations). Clone this repo, then:

cd example
dart pub get
dart analyze

Real output:

warning - lib/bad_examples/features/todo/data/models/todo_remote_model.dart:12:1 - Model name "TodoRemoteModel" should not include DataSource implementation "remote". This violates implementation independence. Rename to "TodoModel". Models should be independent of DataSource implementation. - model_naming_convention
warning - lib/bad_examples/features/todo/data/repositories/todo_repository_impl.dart:25:3 - Repository should NOT use Result pattern. Use pass-through pattern instead. Return Future<Entity> directly. Let errors pass through to AsyncValue.guard(). - repository_pass_through

In VS Code / Android Studio / IntelliJ, the same two warnings appear as inline squiggles in the editor and as entries in the Problems panel β€” no extra setup beyond the Dart/Flutter extension. Hovering a squiggle shows the problem message; the correction message underneath tells you the fix (there is no auto-fix quick action yet, since these rules don't register an analyzer CorrectionProducer). Each file under example/lib/bad_examples/ links to its fixed counterpart in example/lib/good_examples/ in a header comment.

  • Local: docs/config/lint_profile_balanced.yaml
  • CI: docs/config/lint_profile_strict.yaml

See docs/config/RECOMMENDED_SETUP.md for details.

🧩 Compatibility β€” analyzer 14 / Riverpod 3+

Verified consumer matrix (2026-09-04, maintainer SDK + flagship ittae lockfile):

Line Version
Flutter 3.47.2 (Dart 3.13.2)
riverpod / flutter_riverpod 3.3.2
riverpod_generator / riverpod_annotation 4.0.4 / 4.0.3
riverpod_lint (resolved in the plugin host from ^3.1.3) 3.1.9

pub.flutter-io.cn latest Riverpod 3.4.2 is not in this verified set.

The package runs on the official analysis_server_plugin (>=0.3.22 <0.3.23), which pins analyzer 14.3.0; the declared analyzer range >=14.3.0 <15.0.0 therefore resolves to 14.3.0 today and is kept open for the next ASP bump. analysis_server_plugin 0.3.20 fixed the "dart analyze could spin indefinitely" hang that kept 2.4.x on 0.3.15 / analyzer 13. Coexistence with riverpod_lint in one plugin synthetic package requires riverpod_lint 3.1.9 (analyzer >=13.0.0 <15.0.0, Dart >=3.13.0); on Dart 3.12.x a consumer whose riverpod_lint constraint still admits 3.1.8 or older (for example ^3.1.3) is silently resolved back to clean_architecture_linter 2.4.0, while ^3.1.9 fails to solve outright below Dart 3.13 because of its SDK bound. Verified 2026-09-04 on Dart 3.13.2: the flagship ittae tree (with riverpod_lint ^3.1.3) analyzes in 29–40 s (2.4.0: 20–38 s) with no hang across three consecutive runs.

This line also closes a silent-loss bug in the 2.4.x line. With analysis_server_plugin 0.3.15 the plugin reported idle to the analysis server between its first file and its full pass, so dart analyze (which stops collecting at the first idle status) returned with zero or partial plugin diagnostics and exit code 0: on this package's fixtures 11 of 20 runs (2-row fixture) and 7 of 10 runs (16-row fixture) lost rows, and 15 of 15 did under parallel load. analysis_server_plugin 0.3.16 reworked the plugin server so its status follows the analysis driver; on 0.3.22 the same fixtures delivered every row in 55 of 55 dart analyze runs, including 15 under the same load. See tools/verify_analyze_parity.sh for the CI guard that still verifies delivery.

riverpod_lint 3.1.x carries its own analyzer constraints. Keep analyzer plugins out of dev_dependencies and enable both tools through top-level plugins: when you need them in one consumer project. The analyzer plugin manager resolves all enabled plugins in one synthetic package, so this package keeps its analyzer range aligned with riverpod_lint:

plugins:
  clean_architecture_linter: ^2.4.0
  riverpod_lint: ^3.1.9

The v1 custom_lint upstream (invertase/dart_custom_lint) was archived in May 2026. v2.0 moves fully to the official plugin, so the old pubspec_overrides.yaml bridge is no longer needed β€” delete it when upgrading.

πŸŽ›οΈ Configuration

Optional: Test Coverage

In v2.0, rule severity is controlled with the standard analyzer errors: map, keyed by each rule's diagnostic name. Promote a rule to an error, downgrade it to a hint, or silence it:

# analysis_options.yaml
analyzer:
  errors:
    repository_interface: error   # treat as build-breaking
    riverpod_keep_alive: ignore   # silence

The opt-in clean_architecture_linter_require_test (test coverage) rule is not bundled in 2.0.0-dev.1. It will be re-introduced in a later v2 pre-release; track the CHANGELOG.

🚦 Usage

Folder Structure

Organize your Flutter project following Clean Architecture:

lib/
β”œβ”€β”€ {feature_name}/
β”‚   β”œβ”€β”€ domain/
β”‚   β”‚   β”œβ”€β”€ entities/
β”‚   β”‚   β”œβ”€β”€ repositories/
β”‚   β”‚   └── usecases/
β”‚   β”œβ”€β”€ data/
β”‚   β”‚   β”œβ”€β”€ datasources/
β”‚   β”‚   β”œβ”€β”€ models/
β”‚   β”‚   └── repositories/
β”‚   └── presentation/
β”‚       β”œβ”€β”€ providers/
β”‚       β”œβ”€β”€ widgets/
β”‚       └── pages/

Running the Linter

# Run the linter (rules are included in the analyzer output)
dart analyze        # Flutter projects too β€” NOT `flutter analyze` (drops plugin diagnostics)

⚠️ In Flutter projects run dart analyze, not flutter analyze. See docs/analysis/FLUTTER_ANALYZE_PLUGIN_LOSS.md.

IDE Integration

The linter works automatically in:

  • VS Code with the Dart/Flutter extensions
  • IntelliJ IDEA / Android Studio with Flutter plugin

πŸ“š Examples

βœ… Good Examples

Domain Entity (Immutable)

// lib/domain/entities/user_entity.dart
class UserEntity {
  final String id;
  final String name;
  final String email;

  const UserEntity({
    required this.id,
    required this.name,
    required this.email,
  });

  bool isValidEmail() {
    return email.contains('@');
  }
}

Data Model with Database (ObjectBox Example)

// lib/data/models/user_model.dart
import 'package:objectbox/objectbox.dart';  // βœ… Allowed

@Entity()  // βœ… Database annotation instead of @freezed
class UserModel {
  @Id()
  int id = 0;

  String name;
  String email;

  UserModel({required this.name, required this.email});

  // βœ… Private database access is allowed
  static Box<UserModel> get _box => objectBoxService.store.box<UserModel>();

  // Conversion method
  UserEntity toEntity() => UserEntity(
    id: id.toString(),
    name: name,
    email: email,
  );
}

Note: When using database libraries (ObjectBox, Realm, Isar, Drift), Models are mutable and use database-specific annotations instead of @freezed. This is an exception to the standard Freezed pattern.

Repository Interface

// lib/domain/repositories/user_repository.dart
abstract class UserRepository {
  Future<UserEntity> getUser(String id);
  Future<void> saveUser(UserEntity user);
}

UseCase with Single Responsibility

// lib/domain/usecases/get_user_usecase.dart
class GetUserUseCase {
  final UserRepository repository;

  GetUserUseCase(this.repository);

  Future<UserEntity> call(String userId) {
    return repository.getUser(userId);
  }
}

❌ Bad Examples (Will be flagged)

Mutable Domain Entity

// ❌ This will be flagged by entity_immutability
class UserEntity {
  String name; // Non-final field

  void setName(String newName) { // Setter in entity
    name = newName;
  }
}

Domain Layer with External Dependencies

// ❌ This will be flagged by domain_purity
import 'package:http/http.dart'; // External framework import

class UserEntity {
  final String name;
}

UI with Direct Business Logic

// ❌ This will be flagged by business_logic_isolation
class UserWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Business logic in UI layer - WRONG!
    final isValid = email.contains('@') && email.length > 5;
    return Text(isValid ? 'Valid' : 'Invalid');
  }
}

Repository Using Result Pattern

// ❌ This will be flagged - use pass-through pattern instead
class UserRepositoryImpl implements UserRepository {
  @override
  Future<Result<UserEntity, Failure>> getUser(String id) async {
    try {
      final model = await dataSource.getUser(id);
      return Success(model.toEntity());
    } catch (e) {
      return Failure(UserFailure.fromException(e));
    }
  }
}

// βœ… Correct: Pass-through pattern
class UserRepositoryImpl implements UserRepository {
  @override
  Future<UserEntity> getUser(String id) async {
    final model = await dataSource.getUser(id);  // Errors pass through
    return model.toEntity();
  }
}

Layer Dependency Violation

// ❌ This will be flagged by avoid_layer_dependency_violation
// In domain layer file:
import 'package:myapp/data/models/user_model.dart'; // Domain importing Data!

class UserEntity extends UserModel { // Wrong dependency direction
  // ...
}

Missing Exception Prefix

// ❌ This will be flagged by ensure_exception_prefix
class NetworkException extends Exception { // Should be UserNetworkException
  // ...
}

πŸ”„ Common Patterns

Pass-through Error Handling (Recommended)

// βœ… Good: Pass-through pattern
// DataSource throws AppException
class UserRemoteDataSource {
  Future<UserModel> getUser(String id) async {
    try {
      final response = await client.get('/users/$id');
      return UserModel.fromJson(response.data);
    } on DioException catch (e) {
      throw e.toAppException();  // Convert to AppException
    }
  }
}

// Repository passes through (no try-catch)
class UserRepositoryImpl implements UserRepository {
  @override
  Future<UserEntity> getUser(String id) async {
    final model = await dataSource.getUser(id);  // Errors pass through
    return model.toEntity();
  }
}

// UseCase adds business validation
class GetUserUseCase {
  Future<UserEntity> call(String id) {
    if (id.isEmpty) {
      throw const InvalidInputException.withCode('errorValidationIdRequired');
    }
    return repository.getUser(id);  // Pass-through
  }
}

// Presentation uses AsyncValue.guard()
@riverpod
class UserNotifier extends _$UserNotifier {
  @override
  Future<User> build(String id) => ref.read(getUserUseCaseProvider)(id);

  Future<void> refresh() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => ref.read(getUserUseCaseProvider)(id));
  }
}

Proper Exception Naming

// βœ… Good: Proper exception prefixes
class UserNetworkException extends Exception {
  final String message;
  UserNetworkException(this.message);
}

class UserValidationException extends Exception {
  final String field;
  UserValidationException(this.field);
}

For more detailed examples and explanations, see our comprehensive Examples Guide.

πŸ› οΈ Development

Project Structure

clean_architecture_linter/
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ src/
β”‚   β”‚   └── rules/
β”‚   β”‚       β”œβ”€β”€ domain_rules/
β”‚   β”‚       β”œβ”€β”€ data_rules/
β”‚   β”‚       └── presentation_rules/
β”‚   └── clean_architecture_linter.dart
β”œβ”€β”€ example/
β”œβ”€β”€ test/
└── README.md

Contributing

  1. Fork the repository
  2. Create a feature branch
  3. Add tests for new rules
  4. Format your code: dart format .
  5. Ensure all tests pass
  6. Submit a pull request

See CONTRIBUTING.md for detailed guidelines.

πŸ“„ License

This project is licensed under the MIT License - see the LICENSE file for details.

πŸ™ Support

🎯 Roadmap

  • Configuration system for custom naming patterns
  • Support for multiple state management solutions
  • Integration with CI/CD workflows
  • Custom rule creation guide
  • Performance optimizations

Made with ❀️ for the Flutter community

Libraries

clean_architecture_linter
Public library entrypoint for clean_architecture_linter.
main