Best Practices
To get the most out of the Air Framework, follow these professional guidelines.
Module Granularity
Section titled “Module Granularity”- Keep modules focused: A module should represent a single feature or domain (e.g.,
Auth,Catalog,Payment). - Avoid “Shared” or “Common” modules: Instead of a massive shared module, use focused packages or core modules with clear boundaries.
Encapsulation
Section titled “Encapsulation”- Never import between modules: If
Module Aneeds something fromModule B, use the Event Bus or a Service Interface. - Use internal routes: Manage navigation within the module whenever possible to keep the external API small.
State Management
Section titled “State Management”- View Logic vs Business Logic:
- Use
AirStatefor logic that controls the UI (loading states, input validation). - Use
Servicesfor logic that is independent of the UI (API calls, data processing).
- Use
- Naming:
- Pulses (Verbs):
login,fetchData,updateProfile. - Flows (Nouns):
userProfile,isLoading,products.
- Pulses (Verbs):
Dependency Management
Section titled “Dependency Management”- Declare everything: Use
AppModule.dependenciesto explicitly state what other modules your module needs. - Lazy Registration: Use
registerLazySingletonto keep initial load times fast.
Testing
Section titled “Testing”Unit Tests: State Controllers
Section titled “Unit Tests: State Controllers”State classes are plain Dart objects — test them directly without Flutter or a widget tree.
setUp(() { AirDI().clear(); configureAirState();});
tearDown(() => AirDI().clear());
test('increment increases count', () { final state = CounterState(); state.increment(); expect(CounterFlows.count.value, 1);});
test('initial count is zero', () { expect(CounterFlows.count.value, 0);});Service Tests: Mock via AirDI
Section titled “Service Tests: Mock via AirDI”Replace real services with mocks using allowOverwrite: true. Register mocks in setUp — they’re automatically cleared in tearDown.
setUp(() { AirDI().clear(); AirDI().register<AuthService>(MockAuthService(), allowOverwrite: true); AirDI().register<HttpClient>(MockHttpClient(), allowOverwrite: true);});
test('login pulse updates isLoggedIn flow', () async { final state = AuthState(); await state.login(email: 'a@b.com', password: '123'); expect(AuthFlows.isLoggedIn.value, isTrue);});Module Lifecycle Tests
Section titled “Module Lifecycle Tests”Test that a module registers its services correctly through the full lifecycle:
test('ProductsModule registers ProductService', () async { AirDI().clear(); AirDI().register<HttpClient>(MockHttpClient());
final module = ProductsModule(); module.onBind(AirDI()); await module.onInit(AirDI());
expect(AirDI().tryGet<ProductService>(), isNotNull);});Test Isolation
Section titled “Test Isolation”Always clear state between tests — failing to do so causes false positives from shared global state.
setUp(() => AirDI().clear());tearDown(() => AirDI().clear());See the Testing Guide for the complete pattern including widget testing and adapter mocking.
Anti-Patterns
Section titled “Anti-Patterns”Avoid these common mistakes that undermine the framework’s architecture:
- Importing between modules — use the EventBus or shared adapter contracts instead. Direct imports create hidden coupling that defeats modularity.
- Skipping
superin lifecycle hooks — always callsuper.onBind(di),await super.onInit(di),await super.onDispose(di)as the very first statement. - Registering services outside
onBind— DI must be registered synchronously inonBind. Async setup goes inonInit. - Calling
configureAirState()after module registration — it bootstraps the reactive runtime and must be the first call inmain(). - Using
anyas a version constraint — always pin with^major.minor.patchfor reproducible builds. - Putting business logic in widgets — keep widgets as pure rendering functions. All logic belongs in state controllers or services.