Skip to main content

Module independence

SMF rests on one principle: modules do not know each other. Roles, sockets, variants and the checks of the pipeline all exist to keep it that way.

What a module knows​

A module knows three things:

  • the modules in its dependsOn, in one direction only, so firebase_analytics depends on firebase_core and firebase_core knows nothing about it;
  • the roles it provides, requires or uses, through their sockets, their symbols and their data;
  • the app it is part of, meaning its name, its organization, its platforms and its identifiers.

It never learns which modules the user picked. It never refers to what another module generates, such as a class, a file or a package, unless it depends on that module.

What that looks like​

firebase_analytics logs a screen view whenever the screen changes, without knowing go_router. It uses the router role and gives the role a listener of the screen, only when the app has a router:

const SocketContribution.item(
RouterRole.screenListeners,
Fragment(
'logFirebaseScreenView',
imports: [
ImportRef.app(
'core/analytics/firebase_analytics_service.dart',
show: ['logFirebaseScreenView'],
),
],
),
when: {routerRole},
),

In the module's template, the listener exists only inside {{#has_router}}, the presence flag of the router role. An app without a router gets neither the contribution nor the listener.

A feature does not know get_it either. Its composition file calls resolve<T>() of the DI role and hands the services to the feature's state, and get_it, or any other provider of the role, makes that call work. The feature's screens know even less. They talk only to their state, a Cubit through context.read or a provider through ref, and never to the container.

A setting works the same way. The module that owns a setting gives the settings screen role the widget of its row, and whichever module provides the screen shows it. Neither names the other, so the screen has the rows of exactly the modules that the app has. The setting is saved through the preferences role, and its label is translated through the localization role. The module names neither of the modules that provide them; see How settings work.

A screen that needs an account works the same way. Its feature asks for the condition AuthRole.account on the route, and whichever module has the screens of sign-in declares the guard that stands for it, among the built-in modules sign_in. The two know the auth role and not each other; see Conditions of routes.

sign_in itself knows neither the module that keeps the accounts nor the router. It signs in through appSession of the auth role, and its screens neither close themselves nor navigate after a sign-in: two guards read the session, and the router shows the sign-in and leaves it.

What the model enforces​

SMF checks these rules in code. smf create checks the contributions of the modules before it generates an app, and the contract harness runs the same checks in the tests of every module, together with checks of the generated code.

RuleChecked by
A module puts code only into the sockets of its own roles, of the app entry and of the modules it depends on, and contributes data only to its own roles. The when of a contribution names only roles of the module.smf create and the harness
A feature keeps its files in lib/features/<id>/, and infrastructure declares no routes and has no variants.smf create and the harness
The package of a role's provider, such as flutter_riverpod of riverpod, goes into another module only through its variant for the provider or through a dependency on the provider.smf create and the harness
Every file of the app has exactly one owner.smf create and the harness
Code that refers to the symbols of a role that the module only uses exists only in apps with the role: a contribution with that code names the role in when, and a template puts the code inside {{#has_<role>}} or reads it from a variable whose code depends on the role. Data for the role and code for its sockets need neither, because they apply only when the role is present.the harness, in its app without the role
Only the composition file of a feature resolves services. Other code gets them as parameters of its factory function, and only the DI container calls the functions that create them.the harness
Code of a module navigates only to its own routes and to those of the modules it depends on.the harness
Code of a module reads only its own texts and those of the modules it depends on.the harness
A module gets the preferences through a function that it gives the preferences role, or from the DI container. Only the DI container calls createAppPreferences(), and only bootstrap() calls initPreferences().the harness
Code of a module signs in only through appSession of the auth role. It does not call createAuthService(), and only bootstrap() calls initAuth().the harness
The provider of a role renders what the modules give the role: every registration of a service, every entry of the settings screen and every text. So the tests of a module need not look into the files of a provider.the harness
Code imports only the files its module may use: its own, those of the modules it depends on directly, and those of its roles. What a render hook of a role gives may also import the files of the modules that give the role data, such as the screens that a router routes: a fragment of the hook, or a file that it generates. The template of a brick may not, since it is the same whatever the modules give the role. An import that the pipeline adds for a fragment counts for the module of the fragment.the harness
A module that imports or exports the package of a role's provider adds the package itself, even when it depends on the provider.the harness

What it buys​

Any combination of modules works. You can add a module, leave one out or pick another provider of a role, and the app still compiles. Continuous integration generates apps from the modules in many combinations, each module with each provider of the roles it requires or uses and every module together, and runs flutter analyze on each of them.

Providers are replaceable. A second router or DI container can join without a change to the features, because the features never named the first one.

Modules can come from anyone. A module by another author follows the same rules and goes through the same checks. See Extending SMF.