arsync_lints
A powerful lint package for Flutter/Dart that enforces the Arsync 4-Layer Architecture with strict separation of concerns, Riverpod best practices, and clean code standards.
Requirements
- Dart SDK: 3.10.0 or higher
- Flutter SDK: 3.38.0 or higher
This package uses the native analysis_server_plugin system introduced in Dart 3.10, which provides better IDE integration and faster analysis compared to the legacy custom_lint approach.
Overview
arsync_lints treats architectural violations as build errors, not warnings. This ensures your codebase maintains clean architecture from day one and prevents "spaghetti code" from creeping into your project.
Installation
1. Add to your pubspec.yaml
dev_dependencies:
arsync_lints: ^1.0.0
2. Enable the plugin in analysis_options.yaml
# Dart 3.10+ native plugin system
plugins:
arsync_lints:
analyzer:
exclude:
- '**/*.g.dart'
- '**/*.freezed.dart'
Note: The plugins: section is a top-level key, not nested under analyzer:.
3. Restart your IDE
After adding the plugin, restart your IDE (VS Code, Android Studio, IntelliJ) to activate the lints. The diagnostics will appear automatically in your editor.
4. Run analysis
# Analyze your project
dart analyze
Rules Reference
Category A: Architectural Layer Isolation
These rules prevent layers from "leaking" into each other.
| Rule | Target | Description |
|---|---|---|
presentation_layer_isolation |
screens/, widgets/ |
Cannot import repositories, cloud_firestore, http, or dio. Use Dart records instead of parameter classes. |
shared_widget_purity |
widgets/ |
Cannot import providers or Riverpod packages. Each file must have only ONE public widget. |
model_purity |
models/ |
Must use @freezed and have fromJson factory; no provider imports |
repository_isolation |
repositories/ |
Cannot import screens or UI-specific Riverpod (flutter_riverpod, hooks_riverpod) |
Example: presentation_layer_isolation
// BAD - lib/screens/home_screen.dart
import 'package:my_app/repositories/auth_repository.dart'; // ERROR!
import 'package:dio/dio.dart'; // ERROR!
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final repo = AuthRepository(); // Direct repo access!
return Container();
}
}
// GOOD - lib/screens/home_screen.dart
import 'package:my_app/providers/auth_provider.dart';
class HomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider); // Watch provider instead
return Container();
}
}
Example: shared_widget_purity (Single Widget Per File)
// BAD - lib/widgets/buttons.dart
class PrimaryButton extends StatelessWidget {} // OK - first public widget
class SecondaryButton extends StatelessWidget {} // ERROR! Multiple public widgets
// GOOD - lib/widgets/primary_button.dart
class PrimaryButton extends StatelessWidget {}
class _ButtonContent extends StatelessWidget {} // OK - private helper
Example: Use Records Instead of Parameter Classes
// BAD - lib/screens/profile_screen.dart
class UpdateProfileParams {
final String userId;
final String name;
const UpdateProfileParams({required this.userId, required this.name});
}
// GOOD - Use Dart records
typedef UpdateProfileParams = ({
String userId,
String name,
String? phone,
});
// Usage
void updateProfile(UpdateProfileParams params) {
print(params.userId);
}
Category B: Riverpod & State Management
These rules enforce the "Arsync Riverpod Pattern".
| Rule | Target | Description |
|---|---|---|
provider_autodispose_enforcement |
providers/ |
Providers must use .autoDispose or call ref.keepAlive(). |
provider_file_naming |
providers/ |
Files must end with _provider.dart and contain a matching Notifier class |
provider_state_class |
providers/ |
State classes must be @freezed and defined in the same file |
provider_declaration_syntax |
providers/ |
Must use .new constructor syntax (e.g., AuthNotifier.new) |
provider_class_restriction |
providers/ |
Only Notifier classes and @freezed state classes allowed |
provider_single_per_file |
providers/ |
Each file can only have ONE NotifierProvider matching the file name |
viewmodel_naming_convention |
providers/ |
Notifier classes must end with "Notifier" |
no_context_in_providers |
providers/ |
BuildContext cannot be used as a parameter |
async_viewmodel_safety |
providers/ |
Async methods in Notifiers must have try/catch |
Example: provider_file_naming
// File: lib/providers/auth_provider.dart
// GOOD
class AuthNotifier extends Notifier<AuthState> { ... } // Matches file name prefix
// BAD - lib/providers/auth.dart (missing _provider suffix)
// BAD - lib/providers/auth_provider.dart with class UserNotifier (prefix mismatch)
Example: provider_declaration_syntax
// BAD - Explicit generics and closure
final authProvider = NotifierProvider.autoDispose<AuthNotifier, AuthState>(
() => AuthNotifier(),
);
// GOOD - Clean .new syntax
final authProvider = NotifierProvider.autoDispose(AuthNotifier.new);
Example: provider_autodispose_enforcement
// BAD - Memory leak potential
final authProvider = NotifierProvider<AuthNotifier, AuthState>(() {
return AuthNotifier();
}); // ERROR: Missing autoDispose
// GOOD - Option 1: Use autoDispose
final authProvider = NotifierProvider.autoDispose(AuthNotifier.new);
// GOOD - Option 2: Use keepAlive for persistent state
final authProvider = NotifierProvider.autoDispose(AuthNotifier.new);
class AuthNotifier extends Notifier<AuthState> {
@override
AuthState build() {
ref.keepAlive(); // Explicitly opt-in to persistence
return AuthState();
}
}
Example: provider_state_class
// BAD - State class not @freezed
class AuthState {
final bool isLoggedIn;
AuthState(this.isLoggedIn);
}
// GOOD - Immutable state with @freezed
@freezed
class AuthState with _$AuthState {
const factory AuthState({
@Default(false) bool isLoggedIn,
User? user,
}) = _AuthState;
}
Example: provider_single_per_file
// BAD - lib/providers/auth_provider.dart
final authProvider = NotifierProvider.autoDispose(AuthNotifier.new);
final userProvider = NotifierProvider.autoDispose(UserNotifier.new); // ERROR!
// GOOD - One provider per file
// lib/providers/auth_provider.dart -> authProvider
// lib/providers/user_provider.dart -> userProvider
Example: async_viewmodel_safety
// BAD - Unhandled async errors
class UserNotifier extends AsyncNotifier<User> {
Future<void> fetchUser() async {
await userRepository.getUser(); // ERROR: No try/catch
}
}
// GOOD - Proper error handling
class UserNotifier extends AsyncNotifier<User> {
Future<void> fetchUser() async {
try {
await userRepository.getUser();
} catch (e) {
ref.showExceptionSheet(e);
}
}
}
Category C: Repository & Data Integrity
| Rule | Target | Description |
|---|---|---|
repository_provider_declaration |
repositories/ |
Must define a Provider ending with RepoProvider |
repository_dependency_injection |
repositories/ |
Dependencies must be injected via constructor; Ref parameter banned |
repository_class_restriction |
repositories/ |
Only classes with "Repository" in name; files must end with _repository.dart |
repository_no_try_catch |
repositories/ |
Repositories must throw errors, not catch them |
repository_async_return |
repositories/ |
Public methods must return Future<T> or Stream<T> |
Example: repository_provider_declaration
// lib/repositories/auth_repository.dart
// GOOD - Provider at top ending with RepoProvider
final authRepoProvider = Provider<AuthRepository>((ref) {
final dio = ref.watch(dioProvider);
return AuthRepository(dio);
});
class AuthRepository {
final Dio _dio;
AuthRepository(this._dio);
}
Example: repository_dependency_injection
// BAD - Direct instantiation
class AuthRepository {
final Dio _dio = Dio(); // ERROR: Create objects directly
}
// BAD - Ref parameter
class AuthRepository {
final Ref ref; // ERROR: Ref not allowed
AuthRepository(this.ref);
}
// GOOD - Constructor injection
class AuthRepository {
final Dio _dio;
AuthRepository(this._dio); // Injected via provider
}
Example: repository_no_try_catch
// BAD - Swallowing errors
class UserRepository {
Future<User?> getUser(String id) async {
try {
return await api.fetchUser(id);
} catch (e) {
return null; // ERROR: Hiding the error!
}
}
}
// GOOD - Let errors bubble up
class UserRepository {
Future<User> getUser(String id) async {
return await api.fetchUser(id); // Throws on error
}
}
Category D: Code Quality & Complexity
| Rule | Description |
|---|---|
complexity_limits |
Max 4 parameters, max 3 nesting levels, max 60 lines per method, max 120 lines in build(), no nested ternary |
global_variable_restriction |
Top-level variables must be private (_), constants (k prefix), or Providers. Top-level functions must be private, k-prefixed (in constants.dart), or main() |
print_ban |
print() and debugPrint() are banned; use custom logging using log() extension method on Object |
barrel_file_restriction |
No index.dart barrel files in screens/, features/, widgets/, or providers/ |
ignore_file_ban |
// ignore_for_file: comments are banned |
Example: complexity_limits
// BAD - Nested ternary
Widget build(BuildContext context) {
return isLoading
? LoadingWidget()
: hasError
? ErrorWidget() // ERROR: Nested ternary!
: ContentWidget();
}
// GOOD - Use if/else or switch
Widget build(BuildContext context) {
if (isLoading) return LoadingWidget();
if (hasError) return ErrorWidget();
return ContentWidget();
}
// BAD - Too many parameters (max 4)
void updateUser(
String id,
String name,
String email,
String phone,
String address, // ERROR: More than 4 parameters
) {}
// GOOD - Use a parameter object
void updateUser(UpdateUserParams params) {}
// BAD - Method exceeds 60 lines
void processData() {
// ... 61+ lines of code ... // ERROR!
}
// GOOD - Extract into smaller methods
void processData() {
_validateInput();
_transformData();
_saveResult();
}
Example: global_variable_restriction
// BAD - lib/utils/helpers.dart
String globalApiUrl = 'https://api.example.com'; // ERROR!
void helperFunction() {} // ERROR: Top-level function
// GOOD - lib/utils/constants.dart
const kApiUrl = 'https://api.example.com'; // OK: k prefix in constants.dart
void kFormatDate() {} // OK: k prefix in constants.dart
// GOOD - lib/providers/config_provider.dart
final configProvider = Provider((ref) => Config()); // OK: Provider variable
// GOOD - Private functions anywhere
void _internalHelper() {} // OK: Private function
Category E: UI Safety & Consistency
| Rule | Target | Description |
|---|---|---|
hook_safety_enforcement |
build() methods |
Controllers must use hooks; GlobalKey<FormState>() banned in HookWidgets |
scaffold_location |
widgets/ |
Scaffold is not allowed in widgets folder |
asset_safety |
All files | Image.asset() must use constants, not string literals |
file_class_match |
All files | Class name must match file name (snake_case to PascalCase) |
Example: hook_safety_enforcement
// BAD - Memory leak in build
class MyWidget extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = TextEditingController(); // ERROR: Leaks memory!
return TextField(controller: controller);
}
}
// GOOD - Use hooks
class MyWidget extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useTextEditingController();
return TextField(controller: controller);
}
}
// BAD - GlobalKey<FormState> resets on keyboard open/orientation change
class MyFormWidget extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = GlobalKey<FormState>(); // ERROR: Resets unexpectedly!
return Form(key: formKey, child: ...);
}
}
// GOOD - Use GlobalObjectKey with context for stable identity
class MyFormWidget extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = GlobalObjectKey<FormState>(context); // Stable across rebuilds
return Form(key: formKey, child: ...);
}
}
Example: asset_safety
// BAD - Typo-prone string literal
Image.asset('assets/images/logo.png'); // ERROR!
// GOOD - Use constants
// lib/utils/images.dart
class Images {
static const logo = 'assets/images/logo.png';
}
// Usage
Image.asset(Images.logo);
Example: file_class_match
// File: lib/screens/user_profile_screen.dart
// BAD
class ProfilePage {} // ERROR: Should be UserProfileScreen
// GOOD
class UserProfileScreen {} // Matches file name
Complete Rules List
| # | Rule Name | Category | Target |
|---|---|---|---|
| 1 | presentation_layer_isolation |
Architecture | screens/, widgets/ |
| 2 | shared_widget_purity |
Architecture | widgets/ |
| 3 | model_purity |
Architecture | models/ |
| 4 | repository_isolation |
Architecture | repositories/ |
| 5 | provider_autodispose_enforcement |
Riverpod | providers/ |
| 6 | provider_file_naming |
Riverpod | providers/ |
| 7 | provider_state_class |
Riverpod | providers/ |
| 8 | provider_declaration_syntax |
Riverpod | providers/ |
| 9 | provider_class_restriction |
Riverpod | providers/ |
| 10 | provider_single_per_file |
Riverpod | providers/ |
| 11 | viewmodel_naming_convention |
Riverpod | providers/ |
| 12 | no_context_in_providers |
Riverpod | providers/ |
| 13 | async_viewmodel_safety |
Riverpod | providers/ |
| 14 | repository_provider_declaration |
Repository | repositories/ |
| 15 | repository_dependency_injection |
Repository | repositories/ |
| 16 | repository_class_restriction |
Repository | repositories/ |
| 17 | repository_no_try_catch |
Repository | repositories/ |
| 18 | repository_async_return |
Repository | repositories/ |
| 19 | complexity_limits |
Code Quality | lib/ |
| 20 | global_variable_restriction |
Code Quality | lib/ |
| 21 | print_ban |
Code Quality | lib/ |
| 22 | barrel_file_restriction |
Code Quality | lib/ |
| 23 | ignore_file_ban |
Code Quality | lib/ |
| 24 | hook_safety_enforcement |
UI Safety | build() methods |
| 25 | scaffold_location |
UI Safety | widgets/ |
| 26 | asset_safety |
UI Safety | All files |
| 27 | file_class_match |
UI Safety | All files |
Project Structure
For arsync_lints to work correctly, organize your project like this:
lib/
├── main.dart
├── screens/ # UI pages (can use Scaffold)
│ ├── home/
│ │ └── home_screen.dart
│ └── auth/
│ └── login_screen.dart
├── widgets/ # Reusable UI components (no Scaffold, no providers)
│ ├── buttons/
│ │ └── primary_button.dart
│ └── cards/
│ └── user_card.dart
├── providers/ # State management (Riverpod Notifiers)
│ ├── core/ # Infrastructure providers (dioProvider, etc.)
│ │ └── dio_provider.dart
│ ├── auth_provider.dart
│ └── user_provider.dart
├── models/ # Data classes (Freezed)
│ ├── user.dart
│ └── auth_state.dart
├── repositories/ # Data access layer
│ ├── auth_repository.dart
│ └── user_repository.dart
└── utils/
├── constants.dart # k-prefixed constants and functions
└── images.dart # Asset path constants
Suppressing Rules
While // ignore_for_file: is banned, you can still use line-specific ignores for rare exceptions:
// ignore: print_ban
print('Debug only - remove before commit');
CI/CD Integration
Add to your CI pipeline to enforce architecture:
# GitHub Actions example
- name: Run Analysis
run: dart analyze --fatal-infos --fatal-warnings
Philosophy
"Architecture is about intent. These rules make your intent explicit and your boundaries clear."
The Arsync architecture is designed to:
- Prevent spaghetti code - Clear boundaries between layers
- Enable testability - Each layer can be tested in isolation
- Improve maintainability - New developers understand the structure immediately
- Catch issues early - Violations are build errors, not runtime surprises
Contributing
Contributions are welcome! Please feel free to submit issues and pull requests.
License
MIT License - see LICENSE for details.
Libraries
- main
- arsync_lints - A lint package for Flutter/Dart that enforces the Arsync 4-layer architecture with strict separation of concerns, Riverpod best practices, and clean code standards.