Dart — Production Projects & Capstone Architecture
Project 1 — Production CLI Argument Parser
A CLI tool with subcommands, validation, and exit codes. Demonstrates args package, exhaustive switches, and error handling.
import 'package:args/args.dart';
import 'dart:io';
// ── Subcommand definitions (exhaustive enum) ──
enum Command { convert, info, version }
// ── Temperature units (enhanced enum with conversion logic) ──
enum Unit {
celsius('°C', 'Celsius'),
fahrenheit('°F', 'Fahrenheit'),
kelvin('K', 'Kelvin');
final String symbol;
final String name;
const Unit(this.symbol, this.name);
/// Convert FROM this unit TO Celsius.
double toCelsius(double value) => switch (this) {
Unit.celsius => value,
Unit.fahrenheit => (value - 32) * 5 / 9,
Unit.kelvin => value - 273.15,
};
/// Convert FROM Celsius TO this unit.
double fromCelsius(double celsius) => switch (this) {
Unit.celsius => celsius,
Unit.fahrenheit => celsius * 9 / 5 + 32,
Unit.kelvin => celsius + 273.15,
};
static Unit parse(String s) => switch (s.toLowerCase()) {
'c' || 'celsius' => Unit.celsius,
'f' || 'fahrenheit' => Unit.fahrenheit,
'k' || 'kelvin' => Unit.kelvin,
_ => throw UsageException('Unknown unit: $s', 'Use c, f, or k'),
};
double convertTo(double value, Unit target) =>
target.fromCelsius(toCelsius(value));
}
// ── The parser (top-level, reusable) ──
ArgParser buildParser() {
return ArgParser()
..addCommand('convert')
..addCommand('info')
..addCommand('version');
}
// ── Entry point — thin, delegates to handlers ──
void main(List<String> arguments) {
try {
final parser = buildParser();
final results = parser.parse(arguments);
final command = results.command;
if (command == null) {
printUsage(parser);
exit(64); // EX_USAGE
}
final cmd = Command.values.firstWhere(
(c) => c.name == command.name,
orElse: () => throw UsageException('Unknown command', ''),
);
final exitCode = switch (cmd) {
Command.convert => handleConvert(command),
Command.info => handleInfo(command),
Command.version => handleVersion(),
};
exit(exitCode);
} on UsageException catch (e) {
stderr.writeln('Error: ${e.message}');
stderr.writeln(e.usage);
exit(64);
} catch (e, stack) {
stderr.writeln('Unexpected error: $e\n$stack');
exit(70); // EX_SOFTWARE
}
}
int handleConvert(ArgResults args) {
if (args.rest.length != 3) {
throw UsageException(
'convert requires 3 arguments',
'convert <value> <from> <to>',
);
}
final value = double.tryParse(args.rest[0]);
if (value == null) {
throw UsageException('Invalid value: ${args.rest[0]}', 'Value must be a number');
}
final from = Unit.parse(args.rest[1]);
final to = Unit.parse(args.rest[2]);
final result = from.convertTo(value, to);
print('${value.toStringAsFixed(2)}${from.symbol} = '
'${result.toStringAsFixed(2)}${to.symbol}');
return 0;
}
int handleInfo(ArgResults args) {
for (final unit in Unit.values) {
print('${unit.name}: ${unit.symbol} (${unit.name})');
}
return 0;
}
int handleVersion() {
print('tempc v1.0.0');
return 0;
}
void printUsage(ArgParser parser) {
print('Usage: tempc <command> [arguments]');
print(parser.usage);
}
class UsageException implements Exception {
final String message;
final String usage;
UsageException(this.message, this.usage);
@override
String toString() => message;
}
Project 2 — Zero-Copy Stream Pipeline
A data processing pipeline using lazy Iterables, async* generators, and backpressure-aware streams. Zero intermediate allocations.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
// ── A lazy pipeline: read → parse → filter → transform → aggregate ──
// Each stage is lazy (Iterable or Stream) — no intermediate lists allocated.
// Data flows element-by-element through the pipeline.
class LogEntry {
final DateTime timestamp;
final String level;
final String message;
const LogEntry(this.timestamp, this.level, this.message);
factory LogEntry.fromJson(Map<String, dynamic> json) => LogEntry(
DateTime.parse(json['timestamp'] as String),
json['level'] as String,
json['message'] as String,
);
}
// ── Stage 1: read lines lazily (streaming — constant memory) ──
Stream<String> readLines(String path) {
return File(path)
.openRead()
.transform(utf8.decoder)
.transform(const LineSplitter());
}
// ── Stage 2: parse JSON lazily (sync* — pull-based) ──
Iterable<LogEntry> parseEntries(Iterable<String> lines) sync* {
for (final line in lines) {
try {
yield LogEntry.fromJson(jsonDecode(line) as Map<String, dynamic>);
} on FormatException {
// skip invalid lines (don't crash on bad data)
continue;
}
}
}
// ── Stage 3: filter by level (lazy Iterable) ──
Iterable<LogEntry> filterByLevel(Iterable<LogEntry> entries, String level) {
return entries.where((e) => e.level == level);
}
// ── Stage 4: group by hour (async* — push-based with backpressure) ──
Stream<MapEntry<DateTime, List<LogEntry>>> groupByHour(
Stream<LogEntry> entries,
) async* {
var currentHour = <DateTime, List<LogEntry>>{};
LogEntry? last;
await for (final entry in entries) {
final hour = DateTime(
entry.timestamp.year,
entry.timestamp.month,
entry.timestamp.day,
entry.timestamp.hour,
);
currentHour.putIfAbsent(hour, () => []).add(entry);
last = entry;
}
for (final entry in currentHour.entries) {
yield entry;
}
}
// ── Stage 5: aggregate — count errors per hour ──
Future<Map<DateTime, int>> errorCountPerHour(String logPath) async {
final lines = readLines(logPath); // lazy stream
final entries = parseEntries(lines); // lazy iterable
final errors = filterByLevel(entries, 'ERROR'); // lazy filter
// Convert to stream for async grouping:
final grouped = groupByHour(Stream.fromIterable(errors));
final result = <DateTime, int>{};
await for (final hourEntries in grouped) {
result[hourEntries.key] = hourEntries.value.length;
}
return result;
}
// ── Usage — the pipeline processes a 1GB log file with constant memory ──
void main() async {
final counts = await errorCountPerHour('app.log');
for (final entry in counts.entries) {
print('${entry.key}: ${entry.value} errors');
}
}
Project 3 — Isolate-Based Image Processor
A worker pool that distributes image processing across isolates with zero-copy transfer.
import 'dart:isolate';
import 'dart:typed_data';
import 'dart:async';
// ── Task definition (sent to workers) ──
class ImageTask {
final int id;
final TransferableTypedData pixels; // zero-copy transfer of pixel data
final int width;
final int height;
final String operation; // 'grayscale', 'invert', 'blur'
const ImageTask(this.id, this.pixels, this.width, this.height, this.operation);
}
// ── Worker pool — reusable isolates for parallel processing ──
class ImageProcessor {
final int _poolSize;
final List<_ImageWorker> _workers = [];
final _available = <_ImageWorker>[];
final _pending = <Completer<Uint8List>, _ImageWorker>{};
final _queue = <(ImageTask, Completer<Uint8List>)>[];
ImageProcessor(this._poolSize);
Future<void> start() async {
for (var i = 0; i < _poolSize; i++) {
final worker = await _ImageWorker.spawn();
worker.onResult = _handleResult;
_workers.add(worker);
_available.add(worker);
}
}
Future<Uint8List> process(Uint8List pixels, int w, int h, String op) {
final completer = Completer<Uint8List>();
final task = ImageTask(
_queue.length,
TransferableTypedData.fromList([pixels]), // wrap for zero-copy
w, h, op,
);
_queue.add((task, completer));
_processQueue();
return completer.future;
}
void _processQueue() {
while (_available.isNotEmpty && _queue.isNotEmpty) {
final worker = _available.removeLast();
final (task, completer) = _queue.removeAt(0);
_pending[completer] = worker;
worker.process(task, completer);
}
}
void _handleResult(_ImageWorker worker) {
_available.add(worker);
_processQueue();
}
void dispose() {
for (var w in _workers) w.kill();
}
}
class _ImageWorker {
late final Isolate _isolate;
late final SendPort _sendPort;
final _receivePort = ReceivePort();
late void Function(_ImageWorker) onResult;
Completer<Uint8List>? _current;
static Future<_ImageWorker> spawn() async {
final worker = _ImageWorker();
worker._isolate = await Isolate.spawn(_entry, worker._receivePort.sendPort);
worker._sendPort = await worker._receivePort.first as SendPort;
worker._receivePort.listen(worker._handleMessage);
return worker;
}
void process(ImageTask task, Completer<Uint8List> completer) {
_current = completer;
_sendPort.send(task);
}
void _handleMessage(dynamic message) {
if (message is Uint8List && _current != null) {
_current!.complete(message);
_current = null;
onResult(this);
}
}
void kill() {
_isolate.kill(priority: Isolate.immediate);
_receivePort.close();
}
static void _entry(SendPort mainPort) {
final receivePort = ReceivePort();
mainPort.send(receivePort.sendPort);
receivePort.listen((task) {
if (task is ImageTask) {
// Materialize the transferred data (zero-copy — moved, not copied):
final data = task.pixels.materialize().asUint8List();
final result = _processImage(data, task.width, task.height, task.operation);
// Transfer the result back (zero-copy):
mainPort.send(TransferableTypedData.fromList([result]).materialize().asUint8List());
}
});
}
static Uint8List _processImage(Uint8List pixels, int w, int h, String op) {
// Image processing — grayscale, invert, etc.
return switch (op) {
'grayscale' => _grayscale(pixels),
'invert' => _invert(pixels),
_ => pixels,
};
}
static Uint8List _grayscale(Uint8List pixels) {
final result = Uint8List(pixels.length);
for (var i = 0; i < pixels.length; i += 4) {
final gray = (pixels[i] * 0.299 + pixels[i + 1] * 0.587 + pixels[i + 2] * 0.114).toInt();
result[i] = gray;
result[i + 1] = gray;
result[i + 2] = gray;
result[i + 3] = pixels[i + 3]; // alpha unchanged
}
return result;
}
static Uint8List _invert(Uint8List pixels) {
final result = Uint8List(pixels.length);
for (var i = 0; i < pixels.length; i += 4) {
result[i] = 255 - pixels[i];
result[i + 1] = 255 - pixels[i + 1];
result[i + 2] = 255 - pixels[i + 2];
result[i + 3] = pixels[i + 3];
}
return result;
}
}
Project 4 — Full-Stack Capstone (Flutter + dart_frog + Shared Models)
A production full-stack app with shared data models, a REST API, and a Flutter client. Demonstrates monorepo structure, shared packages, and end-to-end types.
capstone/
├── packages/
│ └── models/ # shared data models (freezed + json_serializable)
│ ├── lib/
│ │ ├── models.dart # public API barrel
│ │ └── src/
│ │ ├── task.dart
│ │ └── user.dart
│ ├── pubspec.yaml
│ └── analysis_options.yaml
├── server/ # dart_frog REST API
│ ├── routes/
│ │ ├── index.dart
│ │ ├── tasks/
│ │ │ ├── index.dart
│ │ │ └── [id].dart
│ │ └── _middleware.dart
│ ├── lib/
│ │ └── db.dart # database layer
│ ├── test/
│ │ └── tasks_test.dart
│ ├── pubspec.yaml
│ └── Dockerfile
├── client/ # Flutter app
│ ├── lib/
│ │ ├── main.dart
│ │ ├── api/
│ │ │ └── api_client.dart
│ │ ├── state/
│ │ │ └── task_provider.dart
│ │ └── screens/
│ │ ├── task_list_screen.dart
│ │ └── task_detail_screen.dart
│ ├── pubspec.yaml
│ └── analysis_options.yaml
└── melos.yaml # monorepo management
Shared Models Package
// packages/models/lib/src/task.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'task.freezed.dart';
part 'task.g.dart';
@freezed
class Task with _$Task {
const factory Task({
required String id,
required String title,
required String description,
@Default(false) bool completed,
required DateTime createdAt,
DateTime? completedAt,
}) = _Task;
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
}
// packages/models/lib/models.dart — barrel file
export 'src/task.dart';
export 'src/user.dart';
Server — dart_frog REST API
// server/routes/tasks/index.dart
import 'package:dart_frog/dart_frog.dart';
import 'package:models/models.dart';
import '../../lib/db.dart';
Future<Response> onRequest(RequestContext context) async {
final db = context.read<TaskDatabase>();
return switch (context.request.method) {
HttpMethod.get => Response.json(body: await db.all()),
HttpMethod.post => _createTask(context, db),
_ => Response(statusCode: HttpStatus.methodNotAllowed),
};
}
Future<Response> _createTask(RequestContext context, TaskDatabase db) async {
final body = await context.request.json() as Map<String, dynamic>;
final title = body['title'] as String?;
if (title == null || title.isEmpty) {
return Response(statusCode: 400, body: 'Title is required');
}
final task = Task(
id: _generateId(),
title: title,
description: body['description'] as String? ?? '',
createdAt: DateTime.now(),
);
await db.insert(task);
return Response.json(statusCode: 201, body: task.toJson());
}
String _generateId() => DateTime.now().microsecondsSinceEpoch.toString();
// server/routes/tasks/[id].dart
import 'package:dart_frog/dart_frog.dart';
import 'package:models/models.dart';
import '../../lib/db.dart';
Future<Response> onRequest(RequestContext context, String id) async {
final db = context.read<TaskDatabase>();
return switch (context.request.method) {
HttpMethod.get => _getTask(db, id),
HttpMethod.put => _updateTask(context, db, id),
HttpMethod.delete => _deleteTask(db, id),
_ => Response(statusCode: HttpStatus.methodNotAllowed),
};
}
Future<Response> _getTask(TaskDatabase db, String id) async {
final task = await db.findById(id);
if (task == null) return Response(statusCode: 404, body: 'Not found');
return Response.json(body: task.toJson());
}
Future<Response> _updateTask(RequestContext context, TaskDatabase db, String id) async {
final task = await db.findById(id);
if (task == null) return Response(statusCode: 404);
final body = await context.request.json() as Map<String, dynamic>;
final updated = task.copyWith(
title: body['title'] as String? ?? task.title,
completed: body['completed'] as bool? ?? task.completed,
completedAt: body['completed'] == true ? DateTime.now() : null,
);
await db.update(updated);
return Response.json(body: updated.toJson());
}
Future<Response> _deleteTask(TaskDatabase db, String id) async {
final deleted = await db.delete(id);
if (!deleted) return Response(statusCode: 404);
return Response(statusCode: 204);
}
Client — Flutter App with Riverpod
// client/lib/api/api_client.dart
import 'package:http/http.dart' as http;
import 'package:models/models.dart';
import 'dart:convert';
class ApiClient {
final String baseUrl;
final http.Client _client;
ApiClient({required this.baseUrl, http.Client? client})
: _client = client ?? http.Client();
Future<List<Task>> getTasks() async {
final response = await _client.get(Uri.parse('$baseUrl/tasks'));
if (response.statusCode != 200) throw ApiException(response.statusCode);
final list = jsonDecode(response.body) as List;
return list.map((e) => Task.fromJson(e as Map<String, dynamic>)).toList();
}
Future<Task> createTask(String title, String description) async {
final response = await _client.post(
Uri.parse('$baseUrl/tasks'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'title': title, 'description': description}),
);
if (response.statusCode != 201) throw ApiException(response.statusCode);
return Task.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<void> deleteTask(String id) async {
final response = await _client.delete(Uri.parse('$baseUrl/tasks/$id'));
if (response.statusCode != 204) throw ApiException(response.statusCode);
}
}
class ApiException implements Exception {
final int statusCode;
ApiException(this.statusCode);
@override
String toString() => 'ApiException: $statusCode';
}
// client/lib/state/task_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/api_client.dart';
import 'package:models/models.dart';
final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient(baseUrl: 'http://localhost:8080');
});
final taskListProvider = FutureProvider<List<Task>>((ref) async {
return ref.read(apiClientProvider).getTasks();
});
final taskActionsProvider = Provider<TaskActions>((ref) {
return TaskActions(ref.read(apiClientProvider), ref);
});
class TaskActions {
final ApiClient api;
final Ref ref;
TaskActions(this.api, this.ref);
Future<void> create(String title, String description) async {
await api.createTask(title, description);
ref.invalidate(taskListProvider); // refresh the list
}
Future<void> delete(String id) async {
await api.deleteTask(id);
ref.invalidate(taskListProvider);
}
}
Production Checklist
## Null Safety & Types
- [ ] Sound null safety (no `!` abuse, proper `?`/`??`/`?.`)
- [ ] `Object?` over `dynamic` for "any value" (preserve type checking)
- [ ] `late final x = initializer` for lazy fields (not bare `late`)
## Control Flow & Patterns
- [ ] Exhaustive switches (no `default`/`_` for enums/sealed types)
- [ ] Pattern matching with destructuring (no manual casts)
- [ ] `assert` only for debug invariants (stripped in AOT)
## Error Handling
- [ ] Specific `on Type catch` (not broad `catch (e)`)
- [ ] `rethrow` (not `throw e`) to preserve stack traces
- [ ] `Exception` subtypes for recoverable, `Error` subtypes for bugs
- [ ] `runZonedGuarded` for uncaught async errors
## Async & Concurrency
- [ ] `async`/`await` (no unawaited futures — use `unawaited()`)
- [ ] `Future.wait` for parallel independent operations
- [ ] `mounted` check after `await` (before `setState`/`context`)
- [ ] `TransferableTypedData` for large buffers between isolates
- [ ] Isolates for CPU-heavy, `await` for I/O
## Classes & Generics
- [ ] `sealed` for closed hierarchies (exhaustive switches)
- [ ] `const` constructors for immutable value types
- [ ] `==`/`hashCode` override for value classes (or use records)
- [ ] `is T` (not `as T`) for generic cache retrieval
## Collections
- [ ] `fold` (not `reduce`) for possibly-empty collections
- [ ] `[...list]..sort()` for sorted copies (sort is in-place, void)
- [ ] Chain lazy `Iterable` ops, `.toList()` once at the end
- [ ] `List.unmodifiable([...source])` for immutable copies
## Security
- [ ] Parameterized SQL queries (never string interpolation)
- [ ] Input validation (server-side, before processing)
- [ ] No secrets in code (use env vars / secret managers)
## Cross-Platform
- [ ] `dart:io` not used on web (conditional imports)
- [ ] `BigInt` for large integers on web (int precision above 2^53)
- [ ] `http` package for cross-platform HTTP
## Flutter
- [ ] `const` widgets where possible (canonicalization, zero rebuild)
- [ ] `build()` is pure (no side effects, no setState)
- [ ] `initState` for async work, `mounted` check before setState
- [ ] `ListView.builder` for long lists (lazy building)
- [ ] `ValueKey` for items that can reorder
## Tooling & CI
- [ ] `dart format` applied (CI: `--set-exit-if-changed`)
- [ ] `dart analyze` clean (CI: `--fatal-infos`)
- [ ] `dart test` in CI (tests for all logic)
- [ ] `build_runner` generated files not edited (regenerate from source)
- [ ] `pubspec.lock` committed for apps (reproducible builds)
💡 Tips & Tricks
- Performance: use
TransferableTypedDatafor large byte buffers between isolates — zero-copy transfer (O(1)) instead of deep copy (O(n)). The original buffer is invalidated after transfer. Use for images, audio, large arrays. - Idiom: use lazy
Iterable/Streampipelines — chainmap/where/asyncMapand materialize once at the end. Processes data element-by-element with constant memory. Avoid intermediate.toList()calls. - Idiom: monorepo with shared models package —
freezed+json_serializablemodels inpackages/models/, imported by both server and client. End-to-end type safety — the server and client use the sameTasktype. - Idiom:
dart compile exefor native server binaries — AOT-compiled, fast cold start, no runtime needed. Perfect for Docker containers. Cross-compile per-platform. - Idiom: use
melosfor monorepo management —melos bootstrapinstalls deps for all packages,melos exec -- dart testruns tests across packages. Avoids manualcd+pub getfor each package.
⚠️ Edge Cases & Gotchas
- Monorepo path dependencies:
shared_models: path: ../shared_models— changes to the shared package requiremelos bootstrap(ordart pub getin each consumer) to propagate. freezedrequiresbuild_runner: after changing a@freezedclass, rundart run build_runner buildto regenerate.freezed.dartand.g.dart.- Web
intprecision: in the Flutter client,intabove 2^53 loses precision on web. If the server sends large IDs (e.g., snowflake IDs), useStringorBigIntfor the ID type. - CORS in development: the Flutter web client needs CORS headers from the server. Use the
corsMiddlewarein shelf/dart_frog for development. In production, serve the client and API from the same origin (or configure CORS). dart compile exeis platform-specific: a macOS binary doesn't run on Linux. Build per-platform (use Docker for cross-compilation or CI runners per platform).- Async gaps in Flutter: after
awaitin a widget, checkmounted(orcontext.mounted) beforesetStateor navigation. The widget may have been disposed during the await. - Isolate spawn overhead (~50ms): for many small tasks, use a worker pool (reuse isolates). For one-shot heavy work,
Isolate.runis fine. Stream.toList()on infinite streams: never completes. Use.take(n).toList()or.listen()for infinite streams.- Generated files and git: either commit
.g.dart/.freezed.dart(avoids build step for consumers) or.gitignorethem (cleaner diffs, but consumers must runbuild_runner). Be consistent.
🧠 Spot the Bug
A full-stack app shares a Task model between server and client. The server returns created_at (snake_case), but the client model expects createdAt (camelCase):
@freezed
class Task with _$Task {
const factory Task({
required String id,
required String title,
required DateTime createdAt, // expects "createdAt" in JSON
}) = _Task;
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
}
The server sends {"id": "1", "title": "Test", "created_at": "2026-01-01T00:00:00"}. What happens?
Answer
_$TaskFromJson looks for json['createdAt'] (camelCase, matching the field name). The server sends created_at (snake_case). json['createdAt'] is null — DateTime.parse(null) throws FormatException.
The fix — use @JsonKey to map the JSON key to the Dart field name:
@freezed
class Task with _$Task {
const factory Task({
required String id,
required String title,
@JsonKey(name: 'created_at') required DateTime createdAt, // ← map JSON key
}) = _Task;
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
}
Now _$TaskFromJson reads json['created_at'] and maps it to createdAt. The generated code (after build_runner build) handles the key mapping.
Or, configure json_serializable globally to use snake_case:
@JsonSerializable(fieldRename: FieldRename.snake)
// or on freezed:
@Freezed(fieldRename: FieldRename.snake)
This maps ALL fields to snake_case in JSON. Use when the entire API uses snake_case (common in Go/Python backends).