Skip to content

Best Practices

To get the most out of the Air Framework, follow these professional guidelines.

  • 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.
  • Never import between modules: If Module A needs something from Module 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.
  • View Logic vs Business Logic:
    • Use AirState for logic that controls the UI (loading states, input validation).
    • Use Services for logic that is independent of the UI (API calls, data processing).
  • Naming:
    • Pulses (Verbs): login, fetchData, updateProfile.
    • Flows (Nouns): userProfile, isLoading, products.
  • Declare everything: Use AppModule.dependencies to explicitly state what other modules your module needs.
  • Lazy Registration: Use registerLazySingleton to keep initial load times fast.

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);
});

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);
});

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);
});

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.

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 super in lifecycle hooks — always call super.onBind(di), await super.onInit(di), await super.onDispose(di) as the very first statement.
  • Registering services outside onBind — DI must be registered synchronously in onBind. Async setup goes in onInit.
  • Calling configureAirState() after module registration — it bootstraps the reactive runtime and must be the first call in main().
  • Using any as a version constraint — always pin with ^major.minor.patch for reproducible builds.
  • Putting business logic in widgets — keep widgets as pure rendering functions. All logic belongs in state controllers or services.