y_lints

Custom lint rules that enforce clean architecture boundaries in Dart and Flutter projects. Classes are tagged with simple annotations (@DomainEntity, @Repository, @DataSource, @FeatureCubit, …) and the lints verify that each tagged class lives in the right folder, follows the right naming convention, and only depends on layers it is allowed to see.

Built on top of custom_lint.

Installation

Add custom_lint and y_lints as dev dependencies:

dev_dependencies:
  custom_lint: ^0.7.0
  y_lints: ^0.0.1

Enable the plugin in your analysis_options.yaml:

analyzer:
  plugins:
    - custom_lint

Then run:

dart run custom_lint

Usage

Import the annotations and tag your classes. The lints do the rest.

import 'package:y_lints/annotations.dart';

// lib/domain/entities/user_entity.dart
@DomainEntity()
class UserEntity {
  const UserEntity({required this.id, required this.name});
  final String id;
  final String name;
}

// lib/domain/repositories/user_repository.dart
@Repository()
abstract class UserRepository {
  Future<UserEntity> fetch(String id);
}

// lib/data/models/user_model.dart
@Model()
class UserModel extends UserEntity {
  const UserModel({required super.id, required super.name});
}

Expected folder layout

lib/
├── domain/
│   ├── entities/              # @DomainEntity — pure entities
│   └── repositories/          # @Repository   — abstract contracts
├── data/
│   ├── models/                # @Model        — DTOs extending entities
│   ├── repositories/          # @RepositoryImpl
│   └── datasources/
│       └── <feature>/
│           ├── i_*.dart       # @DataSource   — contract
│           └── implementations/
│               ├── remote_*.dart  # @RemoteDataSource
│               └── mock_*.dart    # @MockDataSource
└── presentation/
    └── <feature>/
        ├── cubits/<cubit_name>/
        │   ├── *_cubit.dart   # @FeatureCubit
        │   └── *_state.dart   # @FeatureState
        ├── view/
        │   └── *_builder.dart # @FeatureBuilder — state consumer
        └── pages/             # @Page — any location, Page suffix
            └── *_page.dart

Rules

Rule What it checks
domain_entity_purity @DomainEntity classes live under lib/domain/entities/.
repository_purity @Repository classes live under lib/domain/repositories/.
repository_impl_purity @RepositoryImpl classes live under lib/data/repositories/.
datasource_purity @DataSource contracts live under lib/data/datasources/<feature>/ with an i_ file prefix.
remote_datasource_purity @RemoteDataSource classes live in .../implementations/ with a remote_ file prefix.
mock_datasource_purity @MockDataSource classes live in .../implementations/ with a mock_ file prefix.
model_purity @Model classes live under lib/data/models/, end in _model.dart, and extend an *Entity.
feature_cubit_purity @FeatureCubit classes live under lib/presentation/<feature>/cubits/<cubit_name>/ as *_cubit.dart.
feature_state_purity @FeatureState classes live alongside their cubit as *_state.dart.
page_purity @Page classes end with Page.
feature_builder_purity @FeatureBuilder classes live under lib/presentation/<feature>/view/, end with Builder, and extend StatelessWidget/StatefulWidget.
datasource_returns_model Datasource methods return *Model (or collections of them), not entities.
repository_returns_entity Repository methods return *Entity (or collections of them), not models.
datasource_contract_implemented Each @DataSource contract has at least one implementation (@RemoteDataSource or @MockDataSource).
cubit_constructor_dependencies Cubit constructors must not inject datasources. Everything else — repositories, value objects, services — is allowed.
class_suffix_convention Public class names carry the suffix matching their layer (Entity, Repository, Model, Cubit, …).
required_annotation Classes in each layer carry the annotation the layer requires.
dispose_leak Classes that define dispose()/close() must clean up disposable fields (TextEditingController, ScrollController, AnimationController, StreamController, StreamSubscription, Timer, FocusNode, ChangeNotifier, …).
listener_leak Every addListener / addObserver must be paired with removeListener / removeObserver in dispose() / close(). Inline-closure listeners are flagged separately since they can't be removed.
domain_entity_immutable_fields Instance fields on @DomainEntity classes must be final.

Disabling a rule

Disable any rule in analysis_options.yaml:

custom_lint:
  rules:
    - domain_entity_purity: false

Custom folder layout

If your project doesn't live under lib/domain, lib/data, lib/presentation, override the layer roots through a y_lints config block:

custom_lint:
  rules:
    - y_lints:
        root: lib/            # default
        domain: domain/       # default
        data: data/           # default
        presentation: presentation/  # default

All four fields are optional. For example, to rename presentation/ to ui/:

custom_lint:
  rules:
    - y_lints:
        presentation: ui/

Or to move everything under lib/src/:

custom_lint:
  rules:
    - y_lints:
        root: lib/src/

Intra-layer folder names (entities/, repositories/, models/, datasources/, cubits/, view/) and file conventions (i_*.dart, remote_*.dart, mock_*.dart, *_model.dart, *_cubit.dart, *_state.dart) are fixed.

License

MIT — see LICENSE.

Libraries

annotations
y_lints