Skip to main content

Write an infrastructure module

An infrastructure module sets up a library or a service without screens of its own. It declares no routes, keeps nothing in lib/features/, has no variants, and never takes services from the DI container itself. It puts its code and settings into the app entry, which every module may use, and into the sockets of the roles and modules it declares.

The examples on this page come from two small modules that were generated into an app and checked with flutter analyze: logging, which sets up the logging package, and git, which makes the new app a Git repository.

Start-up code​

bootstrap() runs the start-up code of all modules in four phases: early, platform, DI and late. A module adds a fragment of code to a phase, with the imports it needs:


List<Contribution> contribute(ModuleContext context) => [
const PubspecContribution.hosted('logging', '^1.3.0'),
const SocketContribution.code(
AppEntryRole.bootstrapEarly,
Fragment(
r"Logger.root.onRecord.listen((record) => debugPrint('${record.level.name}: ${record.message}'));",
imports: [
ImportRef('package:flutter/foundation.dart', show: ['debugPrint']),
ImportRef('package:logging/logging.dart', show: ['Logger']),
],
),
),
];

The pipeline adds the imports to lib/bootstrap.dart, merges them with those of other modules, and formats the file:

lib/bootstrap.dart
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:logging/logging.dart' show Logger;

Future<void> bootstrap() async {
Logger.root.onRecord.listen(
(record) => debugPrint('${record.level.name}: ${record.message}'),
);
}

Pick the phase by what the code needs: early for what must come first, platform for platform services such as Firebase.initializeApp, DI for the registration of the services, and late for code that needs the services. Code that must be declared at the top level of lib/bootstrap.dart, such as a handler of background messages, goes into AppEntryRole.topLevel. lib/bootstrap.dart imports neither material nor cupertino, so start-up code does not depend on a design library.

Wrappers around the app​

AppEntryRole.rootWrappers wraps the root widget in main(), and AppEntryRole.appBuilder wraps the content of every route, in the builder of the root MaterialApp. A wrapper has an opening and a closing part. This is how riverpod adds its ProviderScope:

const SocketContribution.wrap(
AppEntryRole.rootWrappers,
Fragment.wrap(
'ProviderScope(child: ',
')',
imports: [
ImportRef(
'package:flutter_riverpod/flutter_riverpod.dart',
show: ['ProviderScope'],
),
],
),
),

The first contribution is the outermost wrapper, as the order of contributions decides.

AppEntryRole.appArgs sets arguments of the root MaterialApp: theme, darkTheme, themeMode and locale, which take one value, localizationsDelegates, which takes the list items of every contributor, and supportedLocales, which takes the list items of one contributor. The root is created in the build of a widget below the root wrappers, so the expression of an argument may read context. An inherited widget among the root wrappers can give the root a value this way, and the root rebuilds when the widget notifies. The theme role gives the root its mode like this:

const SocketContribution.wrap(
AppEntryRole.rootWrappers,
Fragment.wrap(
'AppThemeModeScope(notifier: appThemeMode, child: ',
')',
imports: [_themeMode],
),
),
// ...
const SocketContribution.arg(
AppEntryRole.appArgs,
'themeMode',
Fragment('AppThemeModeScope.of(context)', imports: [_themeMode]),
),

That context is above the MaterialApp, so it has neither the theme nor the localizations of the app. Two rules of the app entry role make it the same with every module that provides the app entry. The tags of the root wrappers are in the body of main(), which runs the root widget inside them (app_entry.root_wrappers_in_main). And the provider creates the root MaterialApp in a method build(BuildContext context) of a class (app_entry.root_in_build).

Two different values of an argument that takes one are an error that names both contributors, and smf create stops. The items of a second contributor of supportedLocales are a conflict too, also when they are the same items: every delegate of the root has to support each of its locales, so a locale that one contributor adds would break the delegates of another. smf create leaves the module of those items out with a warning, or stops with --strict. In an app with the theme role, the role sets theme, darkTheme and themeMode, and in an app with the localization role, the role sets locale and is the one contributor of supportedLocales. So a module leaves these arguments to the roles: a theme comes from a module that provides the theme role, and a module gives its texts to the localization role instead of bringing a delegate of its own.

Dependencies​

PubspecContribution adds to the pubspec.yaml of the app:

  • PubspecContribution.hosted('logging', '^1.3.0') depends on a package of pub.dev, and dev: true makes it a dev dependency.
  • PubspecContribution.sdk('flutter_localizations') depends on a package of the Flutter SDK.
  • PubspecContribution.flutter(assets: [...], fonts: [...], generate: true) adds to the flutter: section.
  • PubspecContribution.environment(sdk: ..., flutter: ...) constrains the Dart and Flutter versions of the app.

The pipeline intersects the constraints of a package that several modules add, and an empty intersection is an error. Declare environment only when your package needs a newer Dart or Flutter than the app has anyway. flutter_core sets Dart ^3.12.0 and Flutter >=3.44.0, and smf create checks the Flutter SDK against the merged constraints before it generates anything.

A package that the provider of a role brings, such as flutter_riverpod of riverpod, is that provider's. Another module adds it only in its variant for the provider, with the constraint any, or when it depends on the provider. A package that no provider brings is shared, and every module that needs it adds it. A provider brings a package when it adds the package outside a variant with a constraint other than any. In a provider, add a shared package, such as collection, with any. See The packages of a provider.

Native settings​

The app entry has sockets for the native files, so a module never edits them:

AppEntryRole.iosDeploymentTarget.value('16.0'),
AppEntryRole.androidManifestPermissions.key('android.permission.INTERNET'),
AppEntryRole.androidManifestApplicationMeta.entry(
'com.example.sdk.CHANNEL',
const AndroidMetaData.value('stable'),
),
AppEntryRole.infoPlist.entry(
'UIBackgroundModes',
const PlistStringArray(['fetch']),
),
AppEntryRole.gradleSettingsPlugins.entry('io.github.ben-manes.versions', '0.64.0'),
AppEntryRole.gradleAppPlugins.key('io.github.ben-manes.versions'),
AppEntryRole.gradleAppDependencies.entry('androidx.annotation:annotation', '1.9.1'),
  • The minimum iOS version is the highest that any module asks for, and flutter_core gives 15.0.
  • Equal entries of several modules merge, and different values of one key are an error that names both modules. Arrays of strings in Info.plist are united, and Gradle versions take the highest.
  • A key that the template of flutter_core has already, such as CFBundleName, cannot be added again. The Gradle sockets do not take the plugins that the template declares or applies, or the Kotlin plugin, which the Flutter Gradle plugin applies itself.
  • AppEntryRole.mainActivityIntentFilters takes <intent-filter> elements of the main activity as XML, such as deep links.

README sections​

AppEntryRole.readmeSections adds a section to the README.md of the app, under a heading of its own, in Markdown:

AppEntryRole.readmeSections.entry(
'Logging',
'The app logs with the [logging](https://pub.dev/packages/logging) '
'package: create a `Logger` per class. `bootstrap()` prints the '
'records at `Logger.root.level` and above, which is INFO by default.',
),

Use it for what someone who opens the app needs to know about your module, such as how to set up a service again. firebase_core explains there how to configure Firebase.

Notes for coding agents​

Every app has a guide for the coding agents that work on it, AGENTS.md. AppEntryRole.agentSections is the socket of its sections, and a module adds a note to it under a heading, in Markdown:

static const _agentNote = '''
- For the feedback of a tap, call `tapFeedback()` of `lib/core/haptics/haptics.dart`. Do not call `HapticFeedback` yourself: the user can turn the feedback off, and only `tapFeedback()` asks.
- Change the choice only with `chooseHaptics()`, which also saves it when the app has preferences.
''';
AppEntryRole.agentSections.entry('Haptics', AgentNote(_agentNote)),

A note tells what the code of the app does not show: a rule that holds across files, an order, what not to do and what to do instead, a placeholder, a step that needs a person. What the code shows needs no note, and setup that needs a person or an account goes into a README section. The built-in modules keep the texts of their notes in lib/src/agents.dart of their package.

The heading of a note​

  • The template of a role writes the note of the role under the description of the role, such as routerRole.description: where the files of the role are, how code uses it, and what code must not do. Such a note holds whichever module provides the role, so it never names a provider or its package. Only a template contributes it, with AgentNote.ofRole, and the socket refuses one from a module; see Define a role.
  • A module that provides a role adds its note under the same heading, with AgentNote, and the section has the note of the role first. A provider that wraps a package opens its note with a line that names the package, such as "With go_router:". Then it tells where it writes what the role describes, and how to add one more by hand. A provider that wraps a tool of Flutter names the tool instead, as gen_l10n does with "With gen-l10n of Flutter:".
  • A module without a role uses a heading of its own, as haptics does above.

The notes under one heading are united into one section: the notes of roles first, then the others in the order of the ids of their contributors, each once. The section of the app entry comes first in the guide, and the others follow in the order of their headings. So replacing the provider of a role changes only its own note.

The rules of a note​

  • It is true in every app that has it. A note about a file that only some apps have goes into a contribution of its own, with the role of that file in its when. firebase_analytics adds its note on screen views with when: {routerRole}, since only an app with a router has the listener that logs them.
  • What depends on the data of a role comes from the render hook of the role or of the module that provides it, as a fragment for the socket. go_router tells this way where the destinations of the main navigation go, only in an app that has one, and the router role adds what it tells of the guards only in an app with guards.
  • It stays in its section. It has no line that starts a title or another section, with # or ## or with a line of = or - under a line of text, and it closes its fenced code blocks. A heading is one line.
  • It names a file or a directory of the app in backticks, by its path from the root of the app, such as lib/core/haptics/haptics.dart. A file that a later step writes, such as one of a tool that runs after generation, is named as a pattern, with <…> or *, or left to the README.

What is checked​

In every app that the contract harness renders, the rule app_entry.agent_guide_paths reads the paths that the guide names in backticks below a top-level directory of a Flutter project, such as lib/, test/ or android/, and its paths to Dart files. It reports one that the app does not have. It does not read the name of a file at the root of the app, such as pubspec.yaml.

The socket puts its sections in an order of its own, so a when of a note adds nothing to the order of contributions, and no note can make an app impossible to generate.

A test can also hold the notes of the roles against the modules that provide the roles, with roleNoteProblems: see The notes of roles.

Nothing checks what a sentence of a note says about the code, which only a test of that code can pin. A test of the module can pin the names: it reads the note from the app, and checks that each name of code that the note gives is one that a file of the app declares or uses. Test a module shows how a test reads the notes.

Building on another module​

When your module builds on another module itself, and not on a role, depend on it with the id constant of its package:

dependsOn: {FirebaseCoreModule.id},

The other module comes into the app with yours, and the constant makes its package a dependency of yours. Your module may then put code into the sockets of that module. A module can declare sockets of its own, such as SocketRef.module(id, 'setup', CodeSocket()), which only the modules that depend on it directly may fill. Prefer a role whenever one describes what you need, so that the user can pick its provider.

Code generation​

A module that needs build_runner contributes a CodegenRequest, and the builders it needs as dev dependencies:

const PubspecContribution.hosted('json_annotation', '^4.9.0'),
const PubspecContribution.hosted('json_serializable', '^6.9.0', dev: true),
const CodegenRequest(description: 'JSON code of FixtureModel'),

However many modules ask, the pipeline runs dart run build_runner build --force-jit once, after flutter pub get, and adds build_runner to the dev dependencies itself. A library that the builders generate and that code of the app imports, such as lib/core/di/dependencies.config.dart, goes into outputs, so that the pipeline and the harness know about it. Part files, such as .g.dart, need no mention.

Checking the machine​

A module that needs a tool on the machine contributes a Preflight with checks. A check has an id, unique in its module, and a description, which the report of --explain and the warnings show:

/// Checks that Git is installed.
final class GitCheck extends PreflightCheck {
const GitCheck();


String get id => 'git';


String get description => 'Git';


Future<PreflightStatus> check(SmfEnvironment environment) async {
if (await environment.findExecutable('git') != null) {
return const PreflightPassed();
}
return const PreflightMissing(
instructions: 'Install Git from https://git-scm.com/downloads.',
);
}
}
  • check only reads the machine, because --explain runs it too. It does not install anything, log in, ask the user or change files other than its temporary files. It returns PreflightPassed, PreflightMissing with instructions, or PreflightFailed when the check itself could not run.
  • When the check finds something other than what it looks for, such as an older version of the tool, it says what it found in PreflightMissing(found: ...), as a clause such as 'git 2.20.0 is installed'. The pipeline then says that the tool is needed, but git 2.20.0 is installed, rather than that the tool is missing.
  • A missing tool that the check can install is PreflightMissing(installable: true). In a terminal, unless the run skips external setup, the pipeline asks the user, showing what the check found and its instructions, calls install, and runs check again. install returns a ToolInstall with the directories of what it installed, which the pipeline adds to the PATH of later checks and commands.
  • A failed check is a warning by default. Set required only when the app cannot work without the tool. A failed required check leaves the module out of the app, or stops the run with --strict or when no app can be made without the module.
  • Never show the output of a command that can hold a secret, such as a token of a login. The login check of firebase_core reports only the error of the command.
  • Run commands with the processRunner of the environment, and find them with findExecutable, which also looks in the directories of tools installed during the run. The names flutter and dart stand for the Flutter SDK that the pipeline checked. On Windows, a batch file such as dart.bat cannot take arguments with ^, <, >, %, &, | or ", because cmd.exe would read them as its own.
  • A command that may wait a long time, such as one that asks a service over the network, gets a timeout in processRunner.run. The runner then stops the command along with the processes it started, and the result says so with timedOut. The login check of firebase_core gives firebase projects:list 40 seconds.
  • The pipeline shows the progress "Checking the machine" while the checks run, and another progress for a check that runs again after an installation, so a check does not need to show one itself.
  • Read an environment variable of smf with environmentVariable, such as SSH_CONNECTION, which tells the login check of firebase_core that the session is over SSH. On Windows, the name may have any case.

See the checks of firebase_core for checks that install tools and log in.

The order of the checks​

The pipeline checks the machine for a module after the modules it depends on, directly or not. It does not matter where the user named those modules, or whether the pipeline added them. The checks of the other modules keep the order of the modules of the app, and those of the templates of the roles come last.

After an installation, the pipeline runs again only the checks after it that have not passed. So a check of your module may need a tool that a check of a module it depends on installs. It cannot count on a check of any other module, whose place depends on the order in which the user named the modules.

A check from the module you depend on​

A step names only checks of its own module in needs. So when your module needs more of a tool than the module it depends on checks, that module can offer you a check to put into your own Preflight. smf_firebase_core does: for a module that depends on it and runs a command that only newer versions of the Firebase CLI have, it exports FirebaseCliVersionCheck.

const version = FirebaseCliVersionCheck(minimum: '15.6.0');

const Preflight([version]),
PostGenStep(tool, arguments, skippable: true, needs: [version.id]),

smf_firebase_core knows how to ask the Firebase CLI, and your module knows the number: the lowest version that has its command, as three numbers. firebase_auth asks for 15.6.0 this way, the first Firebase CLI that can enable sign-in methods.

  • The check reads firebase --version as the check of the Firebase CLI of firebase_core runs it. It accepts that version and any later one, of a later major version too. A pre-release of the lowest version comes before it.
  • For an older Firebase CLI, it tells which command it found, in which version, and how to update it. It installs nothing: other projects of the user run the Firebase CLI too, so the user updates it.
  • On a machine without the Firebase CLI, it reports it missing, and the check of firebase_core offers the installation. That check runs first, so after the installation the check of the version runs again and reads the version that was installed.

Commands after generation​

A PostGenStep runs a command in the new app, after flutter pub get and code generation:

const Preflight([GitCheck()]),
PostGenStep(
ToolRef('git'),
['init', '--quiet'],
description: 'Creating a Git repository',
skippable: true,
needs: ['git'],
),
✓ Getting the packages of the app (1.0s)
✓ Creating a Git repository (0.0s)
✓ Cleaning up the imports (1.9s)
OptionMeaning
descriptionWhat the step does, for its progress and for the instructions when it is left for later.
interactiveThe command talks to the user, so it runs with the terminal attached, and only in a run that can ask.
externalThe command needs something outside the app, such as an account, so a run with --skip-external-setup leaves it for later.
skippableThe app is complete without it, so the pipeline may leave it for later, printing its command, instead of failing. In a run that can ask, the pipeline asks before it runs the step, so the user can leave it for later too.
needsIds of checks of the module's Preflight. When one has not passed, the step does not run, is not asked about, and is left for later with the check as the reason.
idThe id by which other steps continue this one, a PostGenStepId of the module and a name. See Continuing a step.
followUpOfThe id of the step that this one continues.
noticeWhat the user has to know before the step runs and its description does not tell, such as a side effect of the tool that it runs. The step then runs only once the user agreed to it. See A step with a notice.
hostsThe operating systems on which the step applies at all, such as macOS for a step that changes the Xcode project. On any other system, neither the step nor the steps that continue it run or are left for later.

A step that is not skippable fails the generation when it fails or cannot run, unless it continues a step that is not done. ToolRef names the executable, arguments that always come first, and environment variables. firebase_core, for example, runs flutterfire as ToolRef('dart', prefixArgs: ['pub', 'global', 'run', 'flutterfire_cli:flutterfire']).

When a run ends, it reports the steps that are not done. A step that could not run or failed is a warning. A step that was left for later is in a list after the line that the app is created, with its description, the reason, its command and its notice. The machine and external setup has an example of each.

The step runs in the temporary directory of the app, which then moves to its place, so it must not write the absolute path of its working directory into the app. The pipeline writes the files where Flutter records such paths again in the app's directory.

Continuing a step​

A step can continue another step, such as with a fix of a file that the tool of that step writes. The module of the step to continue gives it an id and publishes the id as a constant of its module class, next to its ModuleId. firebase_core does this for the step that runs flutterfire configure, which has id: configureStep:

final class FirebaseCoreModule extends SmfModule {
static const id = ModuleId('firebase_core');
static const configureStep = PostGenStepId(id, 'configure');
// ...
}

firebase_crashlytics depends on firebase_core, and continues that step with a fix of the build phase for Crashlytics that flutterfire adds to the Xcode project on macOS:

PostGenStep(
ToolRef('ruby'),
['-e', _program, AppEntryRole.xcodeProjectFile], // _program fixes the phase
followUpOf: FirebaseCoreModule.configureStep,
description: 'Fixing the Crashlytics phase of flutterfire for flutter build ipa',
skippable: true,
hosts: {HostOperatingSystem.macos},
),

A step that continues another is part of it:

  • It runs right after the step it continues, once that step succeeded, rather than in its own place among the steps. The user is not asked about it, unless it has a notice.
  • When the step it continues does not run or fails, it does not run either. It is left for later after that step, with that step as the reason, whether it is skippable or not.
  • It applies to the app whenever the step it continues does, so it has no when of its own. Otherwise it runs by its own options, such as needs and skippable.
  • Another step can continue it in turn, and then runs right after it.

A module continues its own steps and the steps of the modules it depends on directly. So the module of a step never learns which modules continue it. The pipeline reports as problems of the module a step whose id names another module, two steps with one id, a step that continues a step it may not continue or a step that the app does not have, a step that continues another and has a when, and steps that continue each other in a cycle.

A step with a notice​

Some steps do more than their description says, such as a tool that also changes something in an account of the user. A step tells the user of that in its notice, and then runs only once the user agreed to it. firebase_auth has such a step. It continues the step of firebase_core that runs flutterfire configure, and enables the sign-in methods of the app in the Firebase project with a script of the app. The Firebase CLI adds a web app to a project that has none, which the description of the step does not tell:

const enableSignInNotice = 'The Firebase CLI adds a web app named "Default '
'Web App" to a project that has no web app, because it enables the '
'methods through one (firebase/firebase-tools#11250). The page '
'https://console.firebase.google.com/project/_/authentication/providers '
'of the Firebase console enables them without it.';
final PostGenStep enableSignIn = PostGenStep(
const ToolRef('dart'),
const [enableSignInScript],
followUpOf: FirebaseCoreModule.configureStep,
description: 'Enabling the sign-in methods of the app in its Firebase '
'project',
notice: enableSignInNotice,
skippable: true,
external: true,
needs: [firebaseCliWithSignIn.id],
);

The step needs firebaseCliWithSignIn, the module's FirebaseCliVersionCheck(minimum: '15.6.0'), and the question of a run in a terminal is one line:

Enabling the sign-in methods of the app in its Firebase project (dart tool/enable_firebase_sign_in.dart), for firebase_auth. The Firebase CLI adds a web app named "Default Web App" to a project that has no web app, because it enables the methods through one (firebase/firebase-tools#11250). The page https://console.firebase.google.com/project/_/authentication/providers of the Firebase console enables them without it. Run it now?

What a run does with such a step:

The runWhat happens to a step with a notice
It can ask, and the step can run.It asks about the step, with the notice in the question: <description> (<command>), for <module>. <notice> Run it now?. Enter answers yes, as for every step. For a step that continues another, the question comes once that step succeeded.
It can ask, and the step cannot run.It does not ask, and leaves the step for later like any other. That is a step that is external in a run that skips external setup, one that needs a check which has not passed, one whose tool is missing, and one that continues a step that is not done.
It cannot ask.It leaves the step for later, although it runs a step without a notice unasked.

When the user declines a step, the steps that continue it are left for later after it, without a question.

The notice follows the command of a step that is left for later, whatever the reason, and --explain shows it under the command. So write it as a sentence or two on one line that read on their own in each of these places, without a reference to the question, such as "answer no". The pipeline reports a notice that is blank or has a line break.

The user may decline the step, so a step with a notice is skippable, and the pipeline reports one that is not.

Services​

An infrastructure module that creates services registers them as data of the DI role, and gets the services they need through the factory, as in FactoryRef('createApiClient', import: ..., deps: [ServiceRef(TypeRef('HttpClient', import: ...))]). The container calls the factory with those services, and the module never calls resolve. See registering services.

A module that implements analytics, crash reporting, events or preferences provides the role instead; see Provide a role. A module that keeps a setting of its own in the preferences gives the preferences role a restorer; see Add a setting.