Overview
Idiomatic, production-grade Dart 3 and Flutter: pub packages, sound null safety, records and patterns, futures and streams, isolates, and the widget lifecycle. Assumes the reader knows the syntax. The value is in what is easy to get wrong: unawaited futures, public fields that never promote, BuildContext used after an await, futures created in build, isolate message copying, and a test filter that matches by substring.
Tooling
dart (or flutter in a Flutter project) is the build tool, package manager, formatter, analyzer and test runner in one.
| Task | Command |
|---|---|
| New package | dart create -t package spool / flutter create --template=package spool / flutter create app |
| Add / remove dep | dart pub add path / dart pub remove path (flutter pub add in Flutter projects) |
| Add dev dep | dart pub add dev:test |
| Resolve | dart pub get (--enforce-lockfile in CI fails on drift from pubspec.lock) |
| Outdated / upgrade | dart pub outdated / dart pub upgrade --major-versions |
| Format | dart format . |
| Analyze | dart analyze --fatal-infos / flutter analyze |
| Apply fixes | dart fix --apply |
| Code generation | dart run build_runner build --delete-conflicting-outputs |
| Test | dart test / flutter test |
| Native binary | dart compile exe bin/spool.dart -o spool |
- Use
dart pub addsopubspec.yamlandpubspec.lockstay in step. Commitpubspec.lockfor applications. - Monorepos (3.6+): a root
pubspec.yamllistsworkspace: [packages/a, packages/b]and each member declaresresolution: workspace, giving one shared resolution and one lockfile. - Generated files (
*.g.dart,*.freezed.dart) come frombuild_runner; never edit them by hand.
# analysis_options.yaml
include: package:lints/recommended.yaml # Flutter: package:flutter_lints/flutter.yaml
analyzer:
language:
strict-casts: true # no implicit downcast from dynamic
strict-inference: true
strict-raw-types: true
errors:
unawaited_futures: error
linter:
rules:
- unawaited_futures
- discarded_futures
- cancel_subscriptions
- close_sinks
- avoid_print
The canonical gate (swap in flutter analyze and flutter test for Flutter packages):
dart format --output=none --set-exit-if-changed .
dart analyze --fatal-infos
dart test
Sound Null Safety
- Types are non-nullable unless marked
T?, and the guarantee is sound: a non-nullable static type never holdsnullat runtime. - Type promotion after a null check applies to local variables and parameters, and (3.2+) to private
finalfields. Public fields and getters never promote, because a subclass or another library could override them. Copy to a local first. !throwsNull check operator used on a null valueat runtime. Each!is an unchecked claim; prefer promotion,??, or an early return.latedefers initialization. Reading alatefield before it is written throwsLateInitializationError.late final x = compute();runscomputelazily on first read.- Mark mandatory named parameters
required. A nullable parameter checked inside the function moves the error from compile time to runtime.
class Spool {
Spool(this.path, {this.maxBytes});
final String path;
final int? maxBytes; // public: never promotes
bool isFull(int written) {
final limit = maxBytes; // a local promotes
if (limit == null) return false;
return written >= limit; // limit is int here
}
}
Types, Records and Patterns
sealedclasses makeswitchexhaustive: adding a subtype turns every non-exhaustive switch into a compile error.- Class modifiers state intent:
final(no subtypes outside the library),base(inherit only),interface(implement only),mixin class. - Records return several values without a class:
(int, String),({int line, int column}). Records compare by value. - Patterns validate and destructure untyped data such as decoded JSON in one step.
- Extension types (3.3) wrap a representation type at zero runtime cost, useful for ids and units.
- Override
==andhashCodetogether, or use a record or a generated data class.
sealed class SpoolEvent {}
final class Appended extends SpoolEvent {
Appended(this.bytes);
final int bytes;
}
final class Rotated extends SpoolEvent {
Rotated(this.segment);
final String segment;
}
String describe(SpoolEvent event) => switch (event) {
Appended(:final bytes) => 'appended $bytes bytes',
Rotated(:final segment) => 'rotated to $segment',
};
extension type SegmentId(int value) {}
Config parseConfig(Object? json) {
if (json case {'path': String path, 'maxBytes': int maxBytes}) {
return Config(path, maxBytes);
}
throw FormatException('invalid config', json);
}
Errors
Exceptiontypes describe expected failures that callers handle.Errortypes (StateError,ArgumentError,RangeError) describe programming bugs; do not catch them in normal flow.- Catch by type with
on FormatException catch (e, st). A barecatch (e)also catchesErrors; keep it to top-level handlers that log and report. rethrowkeeps the original stack trace. When translating an error, useError.throwWithStackTrace(SpoolException(...), st).- An error thrown inside an
asyncfunction completes itsFuturewith that error; nobody sees it unless the future is awaited or handled. - In Flutter, route uncaught errors to reporting through
FlutterError.onErrorandPlatformDispatcher.instance.onError.
final class SpoolException implements Exception {
SpoolException(this.message, {this.path});
final String message;
final String? path;
@override
String toString() => 'SpoolException: $message${path == null ? '' : ' ($path)'}';
}
Future<Config> loadConfig(File file) async {
try {
return parseConfig(jsonDecode(await file.readAsString()));
} on FormatException catch (e, st) {
Error.throwWithStackTrace(SpoolException(e.message, path: file.path), st);
}
}
Async and Streams
- Await every
Future, or hand it tounawaited(...)(fromdart:async) so the intent is visible. Theunawaited_futuresanddiscarded_futureslints enforce this. (a, b).waiton a record of futures (3.0+) runs them concurrently and throws aParallelWaitErrorcarrying every result and error.Future.waitreports only the first error.future.timeout(d)stops waiting; the underlying work keeps running.- Streams are single-subscription by default; a second
listenthrowsBad state: Stream has already been listened to.UseStreamController.broadcast()for multiple listeners. - Cancel every
StreamSubscriptionand close everyStreamControlleryou create (cancel_subscriptions,close_sinks). - An
async*generator pauses atyieldwhile its subscriber is paused, which gives backpressure for free.
Future<Dashboard> load(UserId id) async {
final (profile, orders) = await (api.profile(id), api.orders(id)).wait;
return Dashboard(profile, orders);
}
Stream<Segment> segments(Directory dir) async* {
await for (final entity in dir.list()) {
if (entity is File && entity.path.endsWith('.seg')) {
yield await Segment.open(entity);
}
}
}
final subscription = watcher.events.listen(onEvent, onError: onError);
// ...
await subscription.cancel(); // otherwise the listener and its resources stay alive
Isolates
- Each isolate has its own heap and event loop; there is no shared mutable memory. Messages are copied, except
TransferableTypedDataand the value returned throughIsolate.exit. Isolate.run(() => work())(2.19+) runs one computation on a new isolate and returns its result; use it for parsing or crunching that would stall the UI or a server's event loop.- The closure sent to an isolate carries everything it captures. Capturing
thiscopies the whole object graph, and an unsendable member (aSocket, aReceivePort, a native handle) throwsIllegal argument in isolate message. Capture only plain data. - Long-lived workers:
Isolate.spawnwith aSendPort/ReceivePortpair. Flutter'scompute()wrapsIsolate.run; on the web there are no isolates andcompute()runs on the main thread.
Future<List<Entry>> parseLarge(Uint8List bytes) =>
Isolate.run(() => decodeEntries(bytes)); // bytes are copied into the new isolate
Flutter Essentials
- Widgets are immutable configuration.
constconstructors let Flutter skip rebuilding identical subtrees. setStatewraps a synchronous mutation only. Do async work first, then callsetStatewith the result.- After any
awaitin aStatemethod, checkmounted(orcontext.mountedoutside aState) before touchingcontextor callingsetState. Theuse_build_context_synchronouslylint flags misses. - Create futures and streams in
initState, never inbuild:buildruns often, and a new future restartsFutureBuildereach time. - Dispose controllers, focus nodes, animation controllers and subscriptions in
dispose. - Give list items that can move a
ValueKey(item.id)so their state follows the item. - State management:
ValueNotifier/ChangeNotifierwithListenableBuildercovers small apps. Follow the project's existing Provider, Riverpod or Bloc setup; do not mix them. - Split large
buildmethods into small widget classes; helper methods returning widgets rebuild with their parent and cannot beconst.
class SpoolPage extends StatefulWidget {
const SpoolPage({super.key, required this.client});
final SpoolClient client;
@override
State<SpoolPage> createState() => _SpoolPageState();
}
class _SpoolPageState extends State<SpoolPage> {
late final Future<List<String>> _entries;
@override
void initState() {
super.initState();
_entries = widget.client.list(); // runs once per State
}
Future<void> _purge() async {
await widget.client.purge();
if (!mounted) return; // the page may be gone after the await
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Purged')));
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<String>>(
future: _entries,
builder: (context, snapshot) {
if (snapshot.hasError) return Text('Error: ${snapshot.error}');
final entries = snapshot.data;
if (entries == null) return const CircularProgressIndicator();
return ListView.builder(
itemCount: entries.length,
itemBuilder: (context, i) => ListTile(title: Text(entries[i]), onTap: _purge),
);
},
);
}
}
Testing
package:test provides test, group, setUp, tearDown, addTearDown and the matcher library; flutter_test re-exports it and adds testWidgets with a WidgetTester.
import 'package:mocktail/mocktail.dart';
import 'package:spool/spool.dart';
import 'package:test/test.dart';
class MockClient extends Mock implements SpoolClient {}
void main() {
group('Spool', () {
late MockClient client;
setUp(() => client = MockClient());
test('rotates when the limit is reached', () async {
when(() => client.rotate()).thenAnswer((_) async {});
final spool = Spool(client, maxBytes: 4);
await spool.append([1, 2, 3, 4, 5]);
expect(spool.segments, hasLength(2));
verify(() => client.rotate()).called(1);
});
test('rejects an empty path', () {
expect(() => Spool.open(''), throwsA(isA<ArgumentError>()));
});
test('emits appended then rotated', () async {
when(() => client.rotate()).thenAnswer((_) async {});
final spool = Spool(client, maxBytes: 1);
final events = expectLater(spool.events, emitsInOrder([isA<Appended>(), isA<Rotated>()]));
await spool.append([1, 2]);
await events;
});
});
}
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('purge shows a confirmation', (tester) async {
await tester.pumpWidget(MaterialApp(home: Scaffold(body: SpoolPage(client: FakeClient()))));
await tester.pump(); // resolve the entries future
await tester.tap(find.text('first'));
await tester.pump();
expect(find.text('Purged'), findsOneWidget);
});
}
- Async tests return their
Future; the runner waits for it.expectLaterreturns a future to await when the assertion itself is asynchronous. pumpAndSettlepumps until no frames are scheduled and times out on endless animations such as a spinningCircularProgressIndicator; usepump(duration)there.- Control time with
package:fake_asyncor an injectedClockfrompackage:clock. - Golden tests (
matchesGoldenFile,flutter test --update-goldens) render fonts differently across operating systems; generate and compare them on one platform. - Useful flags:
--name(regex),--plain-name(substring),-t/-xfor tags,-p chrome,--reporter expanded,--coverage=coverage(--coveragefor Flutter).
Loom Test Runner Adapter
Adapters. dart-test and flutter-test. A directory containing pubspec.yaml is a dart package; loom picks flutter-test when pubspec.yaml depends on flutter, else dart-test. loom project detect prints the kind, runner and skill per package; confirm that a pure Dart package reports dart-test, since flutter test needs the Flutter SDK.
Single-test command. Loom runs each contract with the package directory as the working directory:
dart test {file} --plain-name '{test}'
flutter test {file} --plain-name '{test}'
The test field. The test description. --plain-name matches it as a plain substring of each test's full name, which is the enclosing group descriptions and the test description joined by spaces. Example: for test('refuses a spool path that is a symlink', ...) inside group('SpoolWriter', ...), both refuses a spool path that is a symlink and SpoolWriter refuses a spool path that is a symlink select it.
No-match behaviour. Both runners exit non-zero when nothing matches, as captured in loom's runner fixtures: dart test exits 79 and flutter test exits 1, each printing No tests match "<name>". on stderr and No tests ran. on stdout. Loom reads the runner's summary, so a contract test whose name does not match reads as not selected and fails the freeze. dart test colours its output even through a pipe; loom strips the colour codes before parsing, so --no-color is unnecessary.
Writing contract tests.
- A plain
dart testorflutter testruns every file ending in_test.dartundertest/. Put contract tests there, named for their subject:test/spool_writer_contract_test.dart. A file without the_test.dartsuffix is skipped by full runs. - Give each contract test a description that no other test's full name in the file contains, or put the full group-prefixed name in
test; a substring shared by two tests selects both. - Keep apostrophes out of contract descriptions: the value is substituted into a single-quoted shell argument.
- Run
dart pub get(orflutter pub get) before writing contracts, so the freeze run does not resolve packages inside its 300-second limit..dart_tool/must be in.gitignore; the freeze treats every other untracked path as a change, and any extra file you touch (a shared fixture undertest/fixtures/) must match aharnessglob. - For impact-selected tests loom batches files into one command (
dart test f1 f2,flutter test f1 f2).
import 'dart:io';
import 'package:spool/spool.dart';
import 'package:test/test.dart';
void main() {
group('SpoolWriter', () {
late Directory dir;
setUp(() async {
dir = await Directory.systemTemp.createTemp('spool_contract');
addTearDown(() => dir.delete(recursive: true));
});
test('refuses a spool path that is a symlink', () async {
final target = File('${dir.path}/target')..writeAsStringSync('');
final link = Link('${dir.path}/spool');
await link.create(target.path);
await expectLater(SpoolWriter.open(link.path), throwsA(isA<SpoolSymlinkException>()));
expect(target.readAsStringSync(), isEmpty);
});
});
}
contracts:
- id: rejects-symlinked-spool
file: test/spool_writer_contract_test.dart
test: SpoolWriter refuses a spool path that is a symlink
scenario: the spool path is a symlink to an empty file in a fresh temporary directory
rejects: a SpoolWriter.open that follows the link and appends to the target file
Build failures. Dart compiles each test file before running it. A contract test that does not compile yet (it calls an API the stage has not written) fails to load with Failed to load "<file>" and compiler errors; loom reads that as a build failure, which counts as red at freeze time. Only that file fails to load; other test files still run.
Anti-Patterns
// Fire-and-forget future: its error is uncaught and ordering is lost
void save() { repository.write(entry); } // BAD
Future<void> save() => repository.write(entry); // GOOD: the caller awaits
// forEach with an async callback does not wait for anything
items.forEach((item) async => await upload(item)); // BAD: all start, none awaited
for (final item in items) { await upload(item); } // GOOD (or Future.wait for concurrency)
// Trusting decoded JSON with casts
final port = (jsonDecode(body) as Map)['port'] as int; // BAD: TypeError on bad input
if (jsonDecode(body) case {'port': int port}) { /* use port */ } // GOOD: validated
// BuildContext after an await
await client.purge();
Navigator.of(context).pop(); // BAD: the widget may be unmounted
if (context.mounted) Navigator.of(context).pop(); // GOOD
Quick swaps: late added only to silence the analyzer → initialize in the constructor or make it nullable; dynamic parameters → a real type or Object?; print in library code → package:logging; catching Error → fix the bug; a BuildContext stored in a field → pass it per call; mutable global singletons → inject dependencies through constructors.
Expert Practices
Language Gotchas
- Collections compare by identity.
[1] == [1]isfalse. UseListEqualityfrompackage:collection, records (value equality), or a data class.constcollections are canonicalized, soidentical(const [1], const [1])istrue. intdiffers by platform. Nativeintis 64-bit and wraps on overflow; on the web it is a JavaScript number with 53 bits of integer precision. IDs and hashes beyond 2^53 needBigIntor a string on the web.Iterableoperations are lazy.mapandwherererun their callbacks on every iteration; call.toList()once when the result is used more than once or the callback has side effects.- Sync errors inside an
asyncfunction become future errors. A caller that does not await never sees them. DateTime.now()is local time. Store and compare in UTC (toUtc()), and inject a clock in code under test.
Async and Isolate Gotchas
- Unhandled future errors are reported to the zone. In a CLI they terminate the isolate; in Flutter they reach
PlatformDispatcher.instance.onError. Keepunawaited_futureson. - Microtasks run before the next event. A loop that keeps scheduling microtasks (or a chain of already-completed futures) starves timers and I/O.
- Isolate startup is not free. Spawning per small task costs more than the work; batch the input or keep a long-lived worker isolate.
StreamControllerwithoutonCancelkeeps producing after the listener leaves. Stop the producer inonCancel.
Performance
constwidgets and constructors;ListView.builderfor long or unbounded lists.- Animate with
FadeTransitionand similar widgets; animating anOpacityrebuilds and repaints its subtree each frame. - Wrap frequently repainting regions in
RepaintBoundary, and verify with the DevTools performance overlay. - Move JSON decoding of large payloads and other CPU work to
Isolate.run. - Build strings with
StringBufferin loops. Ship CLIs asdart compile exeAOT binaries for fast startup.
Security
- Randomness:
Random.secure()for tokens, nonces and keys;Random()is predictable. - Processes:
Process.run(executable, args)passes arguments directly.runInShell: truewith interpolated input is shell injection. - Paths: normalize with
package:pathand checkp.isWithin(root, candidate). Symlinks defeat that check, so compareFile(candidate).resolveSymbolicLinksSync()against the root or refuse links (FileSystemEntity.isLinkSync). - TLS: never return
truefromHttpClient.badCertificateCallbackoutside a test. - App secrets: values passed with
--dart-defineare compiled into the binary and can be extracted. Keep server secrets on the server. Store user tokens in platform secure storage;SharedPreferencesis plain text on disk.
Verification Checklists
Before marking Dart work done:
-
dart format --output=none --set-exit-if-changed .clean -
dart analyze --fatal-infos(orflutter analyze) clean with strict casts, inference and raw types on -
dart test/flutter testgreen; new behaviour has tests - No
!on values from outside the program; decoded JSON validated with patterns or typed models - Every future awaited or wrapped in
unawaited; every subscription cancelled and controller closed - Dependencies added with
dart pub add; generated files regenerated withbuild_runner - No secrets in
--dart-defineor source;Random.secure()for anything security-related
Flutter review:
- No futures or streams created in
build; controllers disposed indispose -
mounted/context.mountedchecked after everyawaitbefore usingcontextorsetState -
constconstructors used where possible; reorderable list items keyed by identity - Heavy work moved off the UI isolate; the project's state-management approach followed
Loom contracts:
- Contract files live under
test/and end in_test.dart - Each
testvalue is a description or full group-prefixed name that selects exactly one test, with no apostrophes - Packages resolved before the freeze; each contract fails now, by assertion or by failing to load