Define a role
A role describes a part of an app that more than one module could implement, so that other modules can count on it without naming the implementation. The built-in roles live in smf_contracts. A role of yours lives in a package of its own, which its providers and the modules that use it depend on.
Make a part a role when other modules count on it, even if a single module provides it today, so that another provider can join later without a change to the modules that use the part. The sockets of a module are for what only makes sense with that one module, and only the modules that depend on it directly fill them; see Write an infrastructure module.
The examples come from the fixtures of smf_pipeline, which define two roles outside smf_contracts. Continuous integration generates apps with them and analyzes those apps.
The role class
A role is a subclass of Role<D> with a single constant instance. D is the type of the data that modules give the role, and a role that takes no data uses NoDsl.
/// The clock role; see [ClockRole].
const clockRole = ClockRole._();
/// The clock of the app, with the time zones the modules ask for as data
/// and the ticks of the modules as a socket.
final class ClockRole extends Role<String> {
const ClockRole._();
/// Statements that run on every tick, in `tick()`.
static const ticks = SocketRef<CodeSocket>.role(
clockRole,
'ticks',
CodeSocket(),
);
/// `Clock createClock()`, which every provider generates.
static const createClock = RequiredFunction(
'createClock',
path: 'lib/core/clock/clock_factory.dart',
returnType: 'Clock',
);
String get id => 'clock';
String get description => 'Clock';
RoleCardinality get cardinality => RoleCardinality.atMostOne;
List<SocketRef> get sockets => const [ticks];
RoleInterface get interface => const RoleInterface(
files: ['lib/core/clock/clock.dart'],
symbols: [createClock],
);
RoleTemplate<String> get template => const _ClockTemplate();
}
| Member | What it declares |
|---|---|
id | A lower snake_case id, unique among the roles and modules of a command. It names the presence flag has_clock, the tags of the role's sockets, and the owner role:clock of the template's files. |
description | The name of the role in the questions of smf create, such as "Clock: which module provides it?". The messages of smf create name the role by it, followed by "role", in lower case unless it starts with an acronym, such as "the clock role". |
cardinality | exactlyOne, atMostOne or many providers in an app. A role with exactly one provider is added to every app. |
requires, uses | Roles that must be present with this one, and roles it works with when present. The hooks of the role and of its providers can read their data. Every provider requires and uses them too. |
sockets, socketFamilies | The places where other modules put code (see Sockets and contributions). A family has one socket per key, such as the annotations of each screen of the router. |
interface | The files that the role's template generates, and the symbols that every provider must generate, such as createClock(). |
options | Options of smf create that the role reads, such as --start of the router. An option whose every value makes another app is a mode option. |
template | The code of the role that does not depend on its provider. |
moduleRules, structuralRules | Checks of the modules and of the generated code. |
The symbols are functions, classes and extensions at the top level of the file at their path. A function or a class is callable the way other code calls it: with its positionalArguments and namedParameters, and no other required parameters. A RequiredFunction also returns its returnType, if it has one. The parameters of a RequiredClass are those of its unnamed constructor, which is const when constConstructor is set, and the class declares each of its getters itself, as a public instance field or getter, since other code reads them from its instances. An inherited member does not count. The layout role requires AppShell this way, with the getters destinations, currentIndex and onSelect.
A RequiredExtension is an extension on the type that its on names, which declares each of its getters as an instance getter. Other code never calls an extension. It names the extension only to show or to hide it in an import, and reads the getters from the objects that the extension is on. The localization role requires the extension AppTexts on BuildContext this way, with the getter l10n, so that every module reads a text as context.l10n.<getter>. The contract harness checks the symbols in the app of every provider.
openToAllModules makes the sockets and symbols of a role usable by every module without declaring it. Only the app entry is open. Keep a role of yours closed, so that a module that uses it has to say so.
Data
A module that declares the role gives it data in the role's type:
clockRole.data("Europe/Kyiv's zone"),
Data applies only when the role is present, and only its hooks see it, so a module that only uses the role can contribute data unconditionally. The hooks get the data of every module, in the order the modules were chosen, with the module that contributed each. Choose a type that describes what modules need rather than how a provider implements it, the way the router takes the routes of a feature and not go_router code.
The template
The template contributes the files of the role's interface and renders what depends on the data. Its hooks run at four stages of the pipeline:
| Hook | Stage | Does |
|---|---|---|
contribute(context) | Collection | Returns the template's own contributions, such as the brick with the interface. |
validate(input) | Validation | Checks the data of all modules together and returns problems. |
choose(context) | Choices | Asks the user, or reads the role's options, for a decision the data leaves open. |
render(input) | Rendering | Returns fragments for sockets, variables for the template's bricks, and files whose paths depend on the data. |
final class _ClockTemplate extends RoleTemplate<String> {
const _ClockTemplate();
List<Contribution> contribute(ModuleContext context) =>
[BrickContribution(clockRoleBundle)];
RoleOutput render(RoleHookInput<String> input) => RoleOutput(
vars: {
'zones': [
for (final data in input.data) SmfNames.dartString(data.value),
].join(', '),
},
);
}
The template's brick holds the interface and the tag of the role's socket:
/// The clock of the app.
abstract interface class Clock {
/// The time zones the modules of the app asked for.
List<String> get zones;
}
/// The time zones the modules of the app asked for.
const clockZones = <String>[{{{zones}}}];
/// Runs the ticks of the modules of the app.
void tick() {
{{{smf_clock__ticks}}}
}
The hooks of different roles never see each other's results, because all data is collected before validate runs. A hook reads only the data of its own role and of the roles its role requires or uses.
A brick has the same files in every app. When the number of the files, or their paths, depend on the data of the role or on its choice, the render hook returns them in RoleOutput.files, each as its text by its path in the app; see Files of render hooks.
What a template contributes
A template contributes like a module. Besides its bricks, it can put code into the sockets of the app entry and of the roles that its role requires or uses, give those roles data, and add dependencies. The template of the theme role keeps the theme mode this way, whichever module provides the theme:
List<Contribution> contribute(ModuleContext context) => [
BrickContribution(themeRoleBundle),
AppEntryRole.agentSections.entry(
themeRole.description,
AgentNote.ofRole(_agentNote),
),
// ... the scope around the root, and its theme, darkTheme and themeMode.
const SocketContribution.item(
PreferencesRole.restorers,
Fragment('restoreAppThemeMode', imports: [_themeMode]),
),
// The entry of the settings screen, with its texts: only an app with
// a settings screen gets the file of the entry, and only then does
// the app need the texts.
BrickContribution(
themeRoleSettingBundle,
when: const {settingsScreenRole},
),
settingsScreenRole.data(
const SettingsEntry(
widget: TypeRef(
'ThemeModeSetting',
import: ImportRef.app('core/theme/theme_mode_setting.dart'),
),
),
),
localizationRole.data(
TextsData([..._texts.values]),
when: const {settingsScreenRole},
),
];
- The role lists the preferences role in
requires, and the settings screen role and the localization role inuses, so its template may contribute to them. - A file that only some apps have is a brick of its own, contributed with
when, as the entry of the settings screen is here. - The template reads its texts with
localizationRole.expressionOfin its render hook; see Texts. AgentNote.ofRoleadds the note of the role to the guide for coding agents of the app, under the description of the role. It tells what holds whichever module provides the role: where the files of the role are, how code uses it, and what code must not do. It names no provider and no package, which a test checks withroleNoteProblems. Only the template of a role contributes such a note, and a provider adds a note of its own under the same heading; see Notes for coding agents.
How settings work describes the theme role as an example, and Provide the theme what is left for its provider.
Choices and options
When the data leaves a decision open, choose makes it. It reads the role's options and, in a run that can ask, asks the user. The router chooses the start screen this way: its choose takes --start when given and returns the only start candidate when there is one. Otherwise it asks, or, in a run without a terminal, throws an SmfUsageException that names the option.
A role whose choose asks must also implement optionsOf(choice), which returns the option values that make the same choice without asking, such as --start /home. The contract harness answers the questions of a role like a user who presses Enter, then checks that these options reproduce the answer in a run without a terminal, so choices must compare by value.
What a role chooses reaches only the render hooks of its template and of its providers, as RoleHookInput.choice. The contributions of a module are the same whatever the role chose.
A mode option
Some options change what kind of app is generated: an app that every user signs in to is another app than one that lets a guest in. A role declares such an option as a mode option, RoleOption.mode, with a closed list of values. The clock role of the fixtures has one for the hours that its clock shows:
/// `--clock-hours`, how many hours the clock shows: 24, the default, or
/// 12.
static const hoursOption = RoleOption.mode(
name: 'clock-hours',
help: 'How many hours the clock of the app shows: 24, the default, or '
'12.',
values: ['24', '12'],
);
With a mode option, the role states four things, and the contract harness holds its template to them:
| What the role states | What it means for the template |
|---|---|
| The code of the app depends on the value. | The render hooks of the template and of the providers read the choice. |
| The first value is the default, the value of an app that got none. | choose takes the value that the option gives, and the first one when the option is not given. Without a terminal it neither fails nor asks for the lack of the option, and at its question a user who presses Enter gets the first value too. |
| The option reproduces every choice. | optionsOf gives the option with its value for every choice, also for one that the template made without asking. |
| A run in a terminal and a run without one make the same choice. | The choices compare by value. |
So no run needs the option, and a script written before the role had it generates the same app. This is the template of the clock role:
Future<Object?> choose(RoleChoiceContext<String> context) async {
const option = ClockRole.hoursOption;
final values = option.allowed!;
final given = context.option(option.name);
if (given != null) return given;
final environment = context.environment;
if (!environment.interactive) return values.first;
return environment.prompter.select(
'How many hours does the clock show?',
values,
defaultValue: values.first,
);
}
Map<String, String> optionsOf(Object? choice) =>
{ClockRole.hoursOption.name: choice! as String};
The value reaches the code of the app in three ways:
- The template of the role reads the choice in its render hook and writes it into a file of the role as a constant, here
clockHours. - A provider reads it in its render hook, through a function that the role offers, here
clockRole.hoursIn(input), and renders the code of that value alone. - A module that is no provider has no hook, so its code is the same in every app. It reads the constant when the app runs and branches on it.
Every value gets apps in the tests. The harness builds one more app of each provider of the role for each value after the first; see Test a module. The values become part of the names of those apps. So a command refuses a registry with a mode option that has fewer than two values, a value twice, or a value that is not of lower-case letters and digits with single hyphens between them; see a command of your own.
Among the built-in roles, the auth role has a mode option, --auth-mode, with the values required, guest and anonymous. Its template writes the choice into the app as the constant authMode; see Sign-in.
Rules
Module rules run in the validation stage, for every module that declares the role, over the role's data and the module's contributions. The router checks there that the routes of a module have valid paths and parameters, and that the template of every screen has its annotation tags.
Structural rules run in the contract harness, over the text of every generated file and an index of every Dart file with its imports, declarations, calls, property accesses and constructors. A call comes with the declaration and the member it is in, and a method with its parameters. The DI role checks there that only composition files call resolve, and the app entry role that the root MaterialApp is created in the build of a widget.
A structural rule sees the data of its own role. With StructuralRuleInput.inputOf(role) it also reads what the hooks of another role of the app get, when that role requires or uses the role of the rule. The localization role does this to tell which texts the template of such a role may read: its own, and those in the data that the modules give it. inputOf throws for a role that does not build on the role of the rule, and for a role that the app lacks.
A rule is a constant with an id, a sentence that says what it requires, and a top-level function that returns problems. A problem names the module at fault, so that smf create can leave that module out.
A role can also have rules for the modules of a kind, such as the file where they may resolve services. The role defines each as a subclass of KindRule, and a kind lists those that its modules follow in ModuleKind.roleRules. A check of the role reads the one it needs from the kind of a module with ruleOf<T>(), which returns null when the kind lists none. The pipeline applies none of these rules itself. The DI role defines CompositionFile this way, and ModuleKinds.feature lists CompositionFile('lib/features/<id>/<id>_composition.dart').
Conditions for routes
A role that knows something that only some screens of an app need can publish it as a condition for routes, a constant of its class. The badge role of the fixtures publishes two:
/// What a user needs for the routes of the holders of a badge. A module
/// that requires the role says whether the user holds one, with a guard
/// of the routes that stands for the condition.
static const holder = RouteCondition(badgeRole, 'holder');
A feature then asks for the condition on a route, and a module that requires or provides the role declares the guard that stands for it. The two know the role and not each other; see Write a feature module.
- The role generates nothing for a condition. In the app, the guard has the names of the routes that ask, which the router role collects.
- The function of the guard is in a file of the module that declares the guard. The template of the router role imports the files of the modules that give it data, and no file of the template of another role. So the role offers what such a function returns, as the auth role offers
appSession.hasAccount, and the module wraps it in a function of its own. - An app may have no guard for a condition. Its routes that ask then show like any other, and the router role reports nothing.
A role that wants to warn of such an app requires or uses the router role. Its hooks then read the routes and the guards of the app through routerRole.facadeOf(input). The auth role does so in the validate hook of its template:
List<SmfIssue> _openAccountRoutes(RoleHookInput<RoleImplementation> input) {
final facade = routerRole.facadeOf(input);
final asking = facade.routesAsking(AuthRole.account);
if (asking.isEmpty || facade.guardFor(AuthRole.account) != null) {
return const [];
}
// ... a warning that names each route of `asking`.
}
A role that uses the router role this way makes every provider of it use the router role too. So the contract harness builds the apps of such a provider with each router of its registry, and without one.
Providers and users of the role
A provider lists RoleProvider.plain(clockRole) in its providers when its bricks alone implement the role, and subclasses RoleProvider<String> to validate the data or render it (see Provide a role). A module that uses the role declares it in uses. It names the role in the when of the contributions whose code refers to the role's symbols. Its templates refer to those symbols only inside {{#has_clock}}, or through a variable whose code depends on the role. The fixtures define a second role, badgeRole, and a module that uses both adds this line to start-up only in an app with a badge:
SocketContribution.code(
AppEntryRole.bootstrapLate,
Fragment(
'debugPrint(createBadge().labels.join());',
imports: [
const ImportRef('package:flutter/foundation.dart'),
BadgeRole.createBadge.importRef,
],
),
when: const {badgeRole},
),