Skip to main content

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();
}
MemberWhat it declares
idA 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.
descriptionThe 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".
cardinalityexactlyOne, atMostOne or many providers in an app. A role with exactly one provider is added to every app.
requires, usesRoles 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, socketFamiliesThe 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.
interfaceThe files that the role's template generates, and the symbols that every provider must generate, such as createClock().
optionsOptions of smf create that the role reads, such as --start of the router.
templateThe code of the role that does not depend on its provider.
moduleRules, structuralRulesChecks of the modules and of the generated code.

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:

HookStageDoes
contribute(context)CollectionReturns the template's own contributions, such as the brick with the interface.
validate(input)ValidationChecks the data of all modules together and returns problems.
choose(context)ChoicesAsks the user, or reads the role's options, for a decision the data leaves open.
render(input)RenderingReturns fragments for sockets and variables for the template's bricks.
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:

bricks/clock_role/__brick__/lib/core/clock/clock.dart
/// 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.

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.

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 an index of every generated Dart file with its imports, declarations, calls and constructors. The DI role checks there that only composition files call resolve.

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.

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, and its templates refer to those symbols only inside {{#has_clock}}. 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},
),