armature

pub package likes points CI

Feature-based application framework with dependency-graph resolution, reactive stores, typed ports (Pipe / Behavior / Slots), and tasks. Pure Dart; pair with armature_flutter for Flutter UI.

Large applications quickly devolve into a web of providers and singletons. armature solves this by giving each feature explicit dependencies, eager store construction, and extension points (ports) that other features plug into without mutual knowledge.

Install

dependencies:
  armature: ^0.4.0
  armature_flutter: ^0.4.0   # if you want the Flutter integration

Quickstart

A self-contained Pure Dart "hello world" with two features composing through a dependency. The Logger feature subscribes to the Counter feature's store and prints every change.

import 'package:armature/armature.dart';

typedef CounterState = ({int value});

class CounterStore extends Store<CounterState> {
  CounterStore() : super(state: (value: 0));
  void increment() => update((s) => (value: s.value + 1));
}

final counterFeature = createFeature(
  name: 'Counter',
  stores: (_) => (counter: CounterStore()),
  exports: (api) => api.own,
);

// A second feature observes the counter through the dependency edge.
final loggerFeature = createFeature(
  name: 'Logger',
  dependsOn: [counterFeature],
)..onStart((api, cleanup) {
  final counter = api.of(counterFeature).counter;
  cleanup.subscribe(
    counter,
    (_, s) => print('counter: ${s.value}'),
    fireImmediately: true,
  );
  counter.increment();
  counter.increment();
});

Future<void> main() async {
  final container = AppContainer(
    features: [counterFeature, loggerFeature],
  );
  await container.start();
  // Logger prints: counter: 0, counter: 1, counter: 2
  await container.stop();
}

For the Flutter integration (ArmatureApp, slot widgets, StoreBuilder/StoreSelector, debug overlay), see armature_flutter.

Core concepts

Features

  • createFeature({name, dependsOn, optionalDependsOn, stores, exports, ports}) — the sole constructor. Store / export factories are records-based: (counter: CounterStore(), repo: NotesStore()).
  • Lifecycledisabledpendingactive → back to disabled. Stores are constructed eagerly during start(); only onStart reruns on activation cycles.
  • Activation helpersmanualActivation, whenActive(parent), whenInactive(parent), whenAllActive([...]), whenStoreState(...).

Stores

Store<T> wraps reactive state with listeners, async tasks, and structural integration into the feature's scopeApi.

typedef NotesState = ({List<String> items});

class NotesStore extends Store<NotesState> {
  NotesStore() : super(state: (items: const []));

  void add(String text) => update((s) => (items: [...s.items, text]));

  late final persist = createVoidTask(
    fn: () async => await db.write(state.items),
    strategy: TaskStrategy.debounce(Duration(milliseconds: 300)),
  );
}

Ports

Extension points that other features plug into. Three kinds in armature core:

  • Pipe<T> — sequential transformation. Each active handler receives the previous value, returns the next.
  • Behavior<TBranch, TPayload> — priority-based selection. Active handlers return BehaviorDescriptor(...); highest priority wins.
  • Slots (SingleSlot / MultiSlot) — Flutter widgets; live in armature_flutter.
// In owner's ports record:
final themeBehavior = createBehavior<ThemeMode, ThemeData>(name: 'theme');

// In child feature:
nightFeature.useBehavior(layoutFeature.ports.themeBehavior, (api) {
  if (!api.own.night.state.enabled) return null;
  return (branch: ThemeMode.dark, payload: ThemeData.dark());
}, priority: 10);

Tasks

Strategy-backed async operations. strategy: is optional — Store.createTask / createVoidTask default to .queue.

  • .queue (default) — FIFO sequential queue.
  • .once — blocks concurrent invocations until done.
  • .latest — only the most recent input finishes.
  • .debounce(duration) — fires once after quiet period.
  • .throttle(duration, edge) — rate-limit with leading / trailing edge control.

Lifecycle: task.reset() returns the state to TaskIdle (cancels coalesced callers from .latest / .debounce / .throttle(trailing) with TaskError, drops state writes from any in-flight run, clears the .once cache). Pass autoReset: duration at creation to schedule the same transition automatically after TaskDone / TaskFailed.

Picking TError — the third generic decides which thrown values land in TaskFailed (sticky, observable in UI) vs propagate from await task(...) and revert state to TaskIdle:

TError = Behaviour When to use
Exception Exception subclasses stick; Error propagates Default for API / IO. Domain failures show in UI; programming bugs surface to the error handler.
domain class (e.g. ApiError) Only that family sticks Strongly typed errors; UI pattern-matches on specific cases.
Never Nothing sticks Task isn't expected to throw in normal flow — any throw is a bug.
Object Everything sticks, including bugs Last resort. Loses the bug-vs-domain distinction.
// Default for API/IO calls.
late final fetchUser = createTask<int, User, Exception>(
  fn: (id) async => api.getUser(id),
);

// Typed exception hierarchy.
late final placeOrder = createTask<OrderRequest, OrderId, OrderError>(
  fn: (req) async => api.placeOrder(req),
);

// Strict — fn shouldn't fail; any throw escalates to the caller.
late final increment = createVoidTask<int, Never>(
  fn: () async => state.value + 1,
);

Error routing

Everything user-actionable reaches ContainerOptions.errorHandler:

What Error type source
onStart / activation / handler throw HandlerError feature name
Listener throw on featureStatusChanged ListenerError feature name
Listener throw on portChanged ListenerError '<events>'
Port mis-scoped apply PortError feature name
Slot widget build throw RenderError feature name

Advanced surface

package:armature/armature.dart exposes the ~25 symbols you need to author features, stores, tasks, and ports. Framework plumbing — handler / listener typedefs (BehaviorHandler, PipeHandler, TaskFn, StateChangeListener, ...), port base classes (Port, AnyPort, PortType, PortSubscription), individual TaskStrategy* constructor classes, debug-overlay mirrors, and LoggerDebugInfo — lives in a separate barrel:

import 'package:armature/advanced.dart';

Reach for it when you need to type-annotate a handler field, build a custom debug overlay, or extend the framework. Day-to-day feature / store code should not need this import.

Learn more

License

MIT — see LICENSE.

Libraries

advanced
Advanced / framework-adjacent surface of package:armature.
armature
Core public surface of package:armature.
test_utils
Test utilities for code built on top of armature.