On this page23 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. The Async Map of the App
- 2. Loading Three Files at Once
- 3. Parallel Balances
- 4. Events as a Sealed Class
- 5. Publishing Events from the Ledger
- 6. Why the Stream Must Be Broadcast
- 7. Listener One — the Budget Monitor
- 8. Listener Two — the Activity Log
- 9. Wiring and Closing in BudgetApp
- 10. The CLI: Subscribe, Then Shut Down in Order
- 11. A Real-World Example
- Code Examples
- Common Beginner Mistakes
- Best Practices
- Mini Challenge
- Practice Questions
- Practice Answers
- Quick Revision
- Key Takeaways
- What's Next?
Lecture 137 — Async Architecture
| Category | 23 — Final Dart Project |
| Lecture | 137 of 140 |
| Estimated video length | 20 min |
| Target | Dart 3.12.2 |
| Level | Advanced |
Our services are async, but nothing in the app reacts when money moves. In this lecture the ledger announces every change on a broadcast stream, two independent listeners — budget alerts and an activity log — react to it, and the app starts and stops cleanly.
Contents
Prerequisites · Connection · Objectives · Lesson · Code Examples · Common Mistakes · Best Practices · Mini Challenge · Practice Questions · Answers · Takeaways
Every error message in this lecture is real. Each one was produced by running the code shown on Dart 3.12.2, so you will recognize it when you meet it yourself.
Prerequisites
Students should already understand, from Lectures 080–082, 087–097, 100, 106 and 132–136:
Future.wait,async/await, and why everyawaitis a gap where other code runs- streams,
await for,StreamController, broadcast streams,map/where/forEach async*generators; sealed classes, exhaustiveswitch, and object patterns inif case- writing files with
openWriteandFileMode.append - the Budget Tracker project as it stands after Lecture 136
Connection With Previous Lectures
After Lecture 136, the report shows when Transport is OVER BUDGET — but only if you ask for it. Zayan can overspend all week and hear nothing. And nothing records what happened when: the JSON files hold the current state, not its history.
Both need the ledger to announce each change while other parts listen, without the ledger knowing who they are. That is a stream, and this lecture designs the app's whole async flow around one.
Sessions in this lecture were run on 2026-10-17.
demoputs its transactions in the current month (days 1–6), so alerts behave the same on any day; transactions you type get today's date, so your dates will differ.
Learning Objectives
By the end of this lecture you will be able to:
- Load several files in parallel with
Future.waitand explain why it is safe - Model app events as a sealed class hierarchy
- Publish events from a private broadcast
StreamControllerbehind aStreamgetter - Explain why the ledger's stream must be broadcast
- Write an
async*listener that turns events into budget alerts - Write a second listener that appends events to a log file
- Shut the app down in the right order so no listener is cut off
1. The Async Map of the App
Before writing code, here is the flow we are building:
bin/budget_tracker.dart
└─ BudgetApp.open('data')
├─ Future.wait: wallets.json + transactions.json + budgets.json (in parallel)
└─ LedgerService ── events (broadcast stream of LedgerEvent) ──┬─> BudgetMonitor → "! Budget alert"
└─> ActivityLog → data/activity.log
finally: app.close() → stream ends → listeners finish → program exitsThere are three kinds of async work: waiting for files (futures), reacting to changes
(a stream), and shutting down (waiting for listeners). Three files are new —
events/ledger_event.dart, services/budget_monitor.dart, services/activity_log.dart —
and six existing files change.
2. Loading Three Files at Once
Until now, JsonFileRepository read its file on first use. Add a method that reads it now
(lib/src/repository/json_file_repository.dart, below the _cache field):
/// Reads the file now, so later calls need no disk access.
Future<void> load() async => _cache = await _read();The three files do not depend on each other, so we read them together. In lib/src/budget_app.dart, replace the BudgetApp.files factory with a
static open method:
/// Opens the JSON files in [dataDir], reading all three at the same time.
static Future<BudgetApp> open(
String dataDir, {
DateTime Function()? clock,
}) async {
File file(String name) => File(p.join(dataDir, name));
final wallets = JsonFileRepository<Wallet, String>(
file('wallets.json'),
Wallet.fromJson,
);
final transactions = JsonFileRepository<Transaction, String>(
file('transactions.json'),
Transaction.fromJson,
);
final budgetRepo = JsonFileRepository<Budget, Category>(
file('budgets.json'),
Budget.fromJson,
);
await Future.wait([wallets.load(), transactions.load(), budgetRepo.load()]);
final ledger = LedgerService(wallets, transactions, clock: clock);
final budgets = BudgetService(budgetRepo, ledger);
final log = ActivityLog.start(file('activity.log'), ledger.events);
return BudgetApp._(ledger, budgets, log: log);
}Why a static method and not a constructor? A constructor cannot be async — it must return
the object immediately, and we only have the object after the files are read. A static method
can return Future<BudgetApp>.
3. Parallel Balances
The same idea fixes balances() in lib/src/services/ledger_service.dart. Lecture 136 awaited
each balanceOf inside a loop; now all of them start together:
/// Every wallet with its balance, richest first.
Future<List<({Wallet wallet, Money balance})>> balances() async {
final wallets = await _wallets.findAll();
final amounts = await Future.wait([
for (final wallet in wallets) balanceOf(wallet.id),
]);
final result = [
for (var i = 0; i < wallets.length; i++)
(wallet: wallets[i], balance: amounts[i]),
];
result.sort((a, b) => b.balance.compareTo(a.balance));
return result;
}Future.wait returns results in the order the futures were written, so amounts[i]
belongs to wallets[i]. This is safe because balanceOf only reads. Work that
writes (an expense after a funds check) stays sequential.
4. Events as a Sealed Class
A listener needs to know what happened. Create lib/src/events/ledger_event.dart:
import '../models/transaction.dart';
import '../models/wallet.dart';
/// Something that happened in the ledger. Listeners receive these on
/// `LedgerService.events`.
sealed class LedgerEvent {
const LedgerEvent();
}
/// A new wallet was opened.
final class WalletOpened extends LedgerEvent {
const WalletOpened(this.wallet);
final Wallet wallet;
}
/// A transaction was saved.
final class TransactionRecorded extends LedgerEvent {
const TransactionRecorded(this.transaction);
final Transaction transaction;
}Because LedgerEvent is sealed, a switch over it must handle every subclass. If we add a
third event later, the compiler points at every listener that forgets it.
5. Publishing Events from the Ledger
In LedgerService, add import 'dart:async'; and import '../events/ledger_event.dart';,
then a private controller with a public Stream getter (Lecture 093):
final _events = StreamController<LedgerEvent>.broadcast();
/// Every change to the ledger, as it happens. Many listeners may listen.
Stream<LedgerEvent> get events => _events.stream;
/// Ends the [events] stream. Call once, when the app shuts down.
Future<void> close() => _events.close();Then add one line after each save — in openWallet, recordIncome, recordExpense and
transfer:
await _transactions.save(expense);
_events.add(TransactionRecorded(expense));
return expense;The event is added only after the save succeeded. A failed command (unknown wallet, not enough money) throws before this line, so listeners never hear about changes that did not happen.
6. Why the Stream Must Be Broadcast
The app has two listeners on ledger.events. A plain StreamController<LedgerEvent>()
allows only one (Lecture 094). Change .broadcast() away and run demo:
Unhandled exception:
Bad state: Stream has already been listened to.
#0 _StreamController._subscribe (dart:async/stream_controller.dart:695:7)
#1 _ControllerStream._createSubscription (dart:async/stream_controller.dart:854:20)
#2 _StreamImpl.listen (dart:async/stream_impl.dart:516:42)
#3 _StreamIterator._initializeOrDone (dart:async/stream_impl.dart:1077:33)
#4 _StreamIterator.moveNext (dart:async/stream_impl.dart:1053:12)
#5 BudgetMonitor.watch (package:budget_tracker/src/services/budget_monitor.dart)
<asynchronous suspension>
#6 Stream.forEach.<anonymous closure> (dart:async/stream.dart:1250:25)
<asynchronous suspension>The activity log subscribed first, inside open(); the monitor's await for was second and
failed. "Something happened" news is exactly what broadcast streams are for.
7. Listener One — the Budget Monitor
The monitor needs one category's spending and limit. Add to BudgetService
(lib/src/services/budget_service.dart):
/// The spending and limit of one [category] in the month of [month].
Future<CategoryLine> lineFor(Category category, DateTime month) async {
final expenses = (await _ledger.transactions()).inMonth(month).expenses;
return (
category: category,
spent: expenses
.where((e) => e.category == category)
.sumMoney((e) => e.amount),
limit: (await _budgets.findById(category))?.monthlyLimit,
);
}Now create lib/src/services/budget_monitor.dart:
import '../events/ledger_event.dart';
import '../models/category.dart';
import '../models/monthly_report.dart';
import '../models/money.dart';
import '../models/transaction.dart';
import 'budget_service.dart';
import 'ledger_service.dart';
/// A warning that spending in a category is near or over its budget.
typedef BudgetAlert = ({
Category category,
Money spent,
Money limit,
BudgetLevel level,
});
/// Watches the ledger's events and reports budgets in danger.
class BudgetMonitor {
BudgetMonitor(this._ledger, this._budgets);
final LedgerService _ledger;
final BudgetService _budgets;
/// An alert for every expense that leaves its category near or over budget.
///
/// The stream ends when the ledger is closed.
Stream<BudgetAlert> watch() async* {
await for (final event in _ledger.events) {
if (event case TransactionRecorded(
transaction: Expense(:final category, :final date),
)) {
final line = await _budgets.lineFor(category, date);
final limit = line.limit;
if (limit == null) continue;
final level = BudgetLevel.of(line.spent, limit);
if (level == BudgetLevel.warning || level == BudgetLevel.over) {
yield (
category: category,
spent: line.spent,
limit: limit,
level: level,
);
}
}
}
}
}The nested pattern matches only expenses and pulls out category and date in one step.
watch() reads one stream and yields a smaller stream of alerts; when the ledger closes,
the await for ends and so does the alert stream. The ledger knows nothing about budgets.
8. Listener Two — the Activity Log
Create lib/src/services/activity_log.dart:
import 'dart:io';
import '../events/ledger_event.dart';
/// Appends one line per ledger event to a log file.
class ActivityLog {
ActivityLog._(this._sink, Stream<LedgerEvent> events) {
_done = events.map(_describe).forEach(_sink.writeln);
}
/// Starts logging [events] to the end of [file].
factory ActivityLog.start(File file, Stream<LedgerEvent> events) {
file.parent.createSync(recursive: true);
return ActivityLog._(file.openWrite(mode: FileMode.append), events);
}
final IOSink _sink;
late final Future<void> _done;
/// Waits for the event stream to end, then flushes and closes the file.
Future<void> close() async {
await _done;
await _sink.close();
}
static String _describe(LedgerEvent event) => switch (event) {
WalletOpened(:final wallet) =>
'wallet opened: ${wallet.id} (${wallet.owner})',
TransactionRecorded(:final transaction) =>
'recorded ${transaction.id}: ${transaction.describe()}',
};
}map turns each event into a line; forEach writes it and returns a Future that completes
when the stream ends. Keeping it in _done lets close() wait for the last line before
closing the file. FileMode.append makes the log grow across runs.
9. Wiring and Closing in BudgetApp
The other changes in lib/src/budget_app.dart (add imports for services/activity_log.dart and
services/budget_monitor.dart):
/// Wires repositories, services and listeners together.
class BudgetApp {
BudgetApp._(this.ledger, this.budgets, {this.log})
: monitor = BudgetMonitor(ledger, budgets); final LedgerService ledger;
final BudgetService budgets;
final BudgetMonitor monitor;
/// Writes every event to `activity.log`; `null` for in-memory apps.
final ActivityLog? log;
/// Ends the event stream and waits for every listener to finish.
Future<void> close() async {
await ledger.close();
await log?.close();
}BudgetApp.inMemory() keeps working unchanged — it gets a monitor but no log. Finally, add
three exports to lib/budget_tracker.dart: src/events/ledger_event.dart,
src/services/activity_log.dart and src/services/budget_monitor.dart.
10. The CLI: Subscribe, Then Shut Down in Order
In bin/budget_tracker.dart, open the app after the empty-arguments check (help needs no
files) and start listening for alerts:
var app = await BudgetApp.open('data');
var ledger = app.ledger;
var budgets = app.budgets;
var alertsDone = app.monitor.watch().forEach((alert) {
var percent = alert.spent.cents * 100 ~/ alert.limit.cents;
print(
'! Budget alert: ${alert.category.label} is at $percent% of '
'${alert.limit.format()} (${alert.level.label})',
);
});At the end of the big try, add a finally:
} on BudgetException catch (e) {
stderr.writeln('Error: $e');
exitCode = 1;
} finally {
await app.close();
await alertsDone;
}The order matters:
app.close()closes the controller → the event stream ends.- The monitor's
await forfinishes → the alert stream ends →alertsDonecompletes. log.close()waited for itsforEach, then flushed the file.
finally runs on success, on errors and on every return, so every command shuts down the
same way. The bin/ file is long and repetitive; we will split
it into proper command classes in Lecture 139.
11. A Real-World Example
From the project folder, on a fresh data folder (rm -rf data first if you have one):
$ dart run budget_tracker demo
Demo data created for Ali, Sara and Zayan.
$ dart run budget_tracker spend ali 200 food "pizza"
Recorded t0007: 2026-10-17 Food expense (pizza) Rs 200.00
! Budget alert: Food is at 93% of Rs 1500.00 (Near limit)
$ dart run budget_tracker spend zayan 100 transport
Recorded t0008: 2026-10-17 Transport expense Rs 100.00
! Budget alert: Transport is at 140% of Rs 500.00 (OVER BUDGET)
$ dart run budget_tracker spend sara 150 books
Recorded t0009: 2026-10-17 Books expense Rs 150.00
$ cat data/activity.log
wallet opened: ali (Ali)
wallet opened: sara (Sara)
wallet opened: zayan (Zayan)
recorded t0001: 2026-10-01 Income from Allowance Rs 5000.00
recorded t0002: 2026-10-02 Income from Tutoring Rs 1500.00
recorded t0003: 2026-10-03 Food expense (groceries) Rs 1200.00
recorded t0004: 2026-10-04 Books expense Rs 800.00
recorded t0005: 2026-10-05 Transfer ali -> zayan (bus pass share) Rs 500.00
recorded t0006: 2026-10-06 Transport expense Rs 600.00
recorded t0007: 2026-10-17 Food expense (pizza) Rs 200.00
recorded t0008: 2026-10-17 Transport expense Rs 100.00
recorded t0009: 2026-10-17 Books expense Rs 150.00Books has no limit, so Sara's expense raises no alert — but the log still records it. One
event, two reactions. dart analyze reports No issues found!, dart test still passes, and
the project is at the Lecture 137 stage.
Nothing here is CPU-heavy, so isolates (Lecture 098) are not needed: waiting for files is
async work (Lecture 100).
Code Examples
Put each example in a new example/ folder in the project and run it from the project folder,
for instance dart run example/events_demo.dart. They are scratch files, not part of the app.
Most use BudgetApp.inMemory with a fixed clock, so the output never changes.
Example 1 — Listening to Every Event
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final app = BudgetApp.inMemory(clock: () => DateTime(2026, 10, 17));
final printed = app.ledger.events.forEach((event) {
final text = switch (event) {
WalletOpened(:final wallet) => 'opened ${wallet.id}',
TransactionRecorded(:final transaction) => 'saved ${transaction.id}',
};
print('event: $text');
});
await app.ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
await app.ledger.recordIncome('ali', Money(50000), source: 'Allowance');
print('commands done');
await app.close();
await printed;
print('listener done');
}event: opened ali
commands done
event: saved t0001
listener doneHow it works
- Events arrive asynchronously: the listener runs only when
mainpauses at anawait, sosaved t0001prints aftercommands done. await app.close()lets the last event through;await printedwaits for the listener.
Example 2 — Loading in Parallel
It reads the data/ folder left by the session in section 11.
import 'dart:io';
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final wallets = JsonFileRepository<Wallet, String>(
File('data/wallets.json'),
Wallet.fromJson,
);
final transactions = JsonFileRepository<Transaction, String>(
File('data/transactions.json'),
Transaction.fromJson,
);
final budgets = JsonFileRepository<Budget, Category>(
File('data/budgets.json'),
Budget.fromJson,
);
await Future.wait([wallets.load(), transactions.load(), budgets.load()]);
print('wallets: ${await wallets.count()}');
print('transactions: ${await transactions.count()}');
print('budgets: ${await budgets.count()}');
}wallets: 3
transactions: 9
budgets: 3Example 3 — Budget Alerts in Memory
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final app = BudgetApp.inMemory(clock: () => DateTime(2026, 10, 17));
final ledger = app.ledger;
final alertsDone = app.monitor.watch().forEach((alert) {
print('ALERT ${alert.category.label}: ${alert.level.label}');
});
await ledger.openWallet(id: 'zayan', owner: 'Zayan', name: 'Cash');
await ledger.recordIncome('zayan', Money(300000), source: 'Tutoring');
await app.budgets.setBudget(Category.transport, Money(50000));
for (final rupees in [300, 150, 100]) {
final t = await ledger.recordExpense(
'zayan',
Money(rupees * 100),
Category.transport,
);
print('spent ${t.amount.format()}');
}
await app.close();
await alertsDone;
}spent Rs 300.00
spent Rs 150.00
ALERT Transport: Near limit
spent Rs 100.00
ALERT Transport: OVER BUDGETHow it works
- Rs 300 of Rs 500 is 60 % — no alert. Rs 450 is 90 % — "Near limit". Rs 550 is over.
- Each alert prints after its expense's
spentline: the monitor still awaitslineForwhilemaincarries on.
Example 4 — Two Listeners on One Stream
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final app = BudgetApp.inMemory(clock: () => DateTime(2026, 10, 17));
final events = app.ledger.events;
print('broadcast: ${events.isBroadcast}');
final counted = events.length;
final printed = events.forEach(
(e) => print('listener 2 got ${e.runtimeType}'),
);
await app.ledger.openWallet(id: 'sara', owner: 'Sara', name: 'Bank card');
await app.ledger.recordIncome('sara', Money(100000), source: 'Scholarship');
await app.close();
print('listener 1 counted ${await counted} events');
await printed;
}broadcast: true
listener 2 got WalletOpened
listener 2 got TransactionRecorded
listener 1 counted 2 eventslength is a Future that completes only when the stream ends — after close().
Example 5 — Late Listeners Miss Events
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final app = BudgetApp.inMemory(clock: () => DateTime(2026, 10, 17));
final ledger = app.ledger;
await ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
final seen = ledger.events.toList();
await ledger.openWallet(id: 'zayan', owner: 'Zayan', name: 'Cash');
await app.close();
for (final event in await seen) {
if (event case WalletOpened(:final wallet)) print('saw ${wallet.id}');
}
}saw zayanBroadcast streams don't remember: Ali's wallet was announced before anyone listened. That is
why bin/ subscribes before running the command.
Example 6 — An async* Filter for Transfers
import 'package:budget_tracker/budget_tracker.dart';
Stream<Transfer> transfers(Stream<LedgerEvent> events) async* {
await for (final event in events) {
if (event case TransactionRecorded(transaction: Transfer t)) yield t;
}
}
Future<void> main() async {
final app = BudgetApp.inMemory(clock: () => DateTime(2026, 10, 17));
final ledger = app.ledger;
final done = transfers(ledger.events).forEach((t) {
print('${t.walletId} -> ${t.toWalletId}: ${t.amount.format()}');
});
await ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
await ledger.openWallet(id: 'zayan', owner: 'Zayan', name: 'Cash');
await ledger.recordIncome('ali', Money(200000), source: 'Allowance');
await ledger.transfer('ali', 'zayan', Money(50000));
await ledger.recordExpense('ali', Money(10000), Category.fun);
await ledger.transfer('zayan', 'ali', Money(20000));
await app.close();
await done;
}ali -> zayan: Rs 500.00
zayan -> ali: Rs 200.00The same shape as BudgetMonitor.watch(): read every event, yield only some.
Example 7 — Counting Zayan's Expenses with where
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final app = BudgetApp.inMemory(clock: () => DateTime(2026, 10, 17));
final ledger = app.ledger;
final zayanSpent = ledger.events
.map((e) => e is TransactionRecorded ? e.transaction : null)
.where((t) => t is Expense && t.walletId == 'zayan')
.length;
await ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
await ledger.openWallet(id: 'zayan', owner: 'Zayan', name: 'Cash');
await ledger.recordIncome('ali', Money(100000), source: 'Allowance');
await ledger.recordIncome('zayan', Money(100000), source: 'Tutoring');
await ledger.recordExpense('zayan', Money(20000), Category.food);
await ledger.recordExpense('ali', Money(30000), Category.books);
await ledger.recordExpense('zayan', Money(15000), Category.transport);
await app.close();
print('Zayan made ${await zayanSpent} expenses');
}Zayan made 2 expensesExample 8 — The Activity Log on Its Own
import 'dart:io';
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final file = File('data/example.log');
if (file.existsSync()) file.deleteSync();
final app = BudgetApp.inMemory(clock: () => DateTime(2026, 10, 17));
final log = ActivityLog.start(file, app.ledger.events);
await app.ledger.openWallet(id: 'sara', owner: 'Sara', name: 'Bank card');
await app.ledger.recordIncome('sara', Money(80000), source: 'Scholarship');
await app.close();
await log.close();
print(await file.readAsString());
}wallet opened: sara (Sara)
recorded t0001: 2026-10-17 Income from Scholarship Rs 800.00
ActivityLog only needs a Stream<LedgerEvent>, so it works with an in-memory ledger too. We
read the file only after log.close() has flushed it.
Common Beginner Mistakes
1. A single-subscription controller for app events
// final _events = StreamController<LedgerEvent>(); // ✗ two listenersBad state: Stream has already been listened to.Use StreamController.broadcast() when several parts of the app listen.
2. Never closing the stream
Remove await app.close(); from the finally, and add print('All listeners finished.');
after await alertsDone;:
$ dart run budget_tracker demo
Demo data created for Ali, Sara and Zayan.
$ dart run budget_tracker spend zayan 50 fun
Recorded t0007: 2026-10-17 Fun expense Rs 50.00No error — but All listeners finished. never prints (with close(), it does). The monitor's
await for waits for a stream that never ends, so Dart exits and skips the rest.
3. Awaiting the listener before closing
} finally {
// await alertsDone; // ✗ waits for a stream that has not been closed
// await app.close();
}Same symptom: app.close() is never reached, because alertsDone waits for the very stream
close() would end. Close first, then wait.
4. Subscribing after the work
A broadcast stream drops events nobody hears (Example 5). Calling watch() after the command
would miss its alert.
5. Making open an async constructor
// factory BudgetApp.open(String dataDir) async { ... } // ✗error - src/budget_app.dart:39:6 - Factory bodies can't use 'async', 'async*', or 'sync*'. - non_sync_factoryConstructors return the object at once. Use a static Future<BudgetApp> method.
Best Practices
- Use
Future.waitfor independent work that only reads; keep read-check-write steps sequential. - Keep the
StreamControllerprivate; expose only aStreamgetter. - Add an event after the change is saved, so listeners never see something that failed.
- Model events as a sealed class, so every listener's
switchis checked by the compiler. - Shut down in order: close the source, then await every listener, in a
finally.
Mini Challenge
Write example/event_summary.dart: count the events by type with one listener and a
switch, then print the totals after the app closes. Open wallets for Ali and Sara, give Ali
Rs 3000, transfer Rs 500 to Sara, and record a Rs 200 Books expense for Sara.
Wallets opened: 2
Transactions recorded: 3Try it yourself before opening the solution.
Show solution
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final app = BudgetApp.inMemory(clock: () => DateTime(2026, 10, 17));
final ledger = app.ledger;
var wallets = 0;
var transactions = 0;
final counted = ledger.events.forEach((event) {
switch (event) {
case WalletOpened():
wallets++;
case TransactionRecorded():
transactions++;
}
});
await ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
await ledger.openWallet(id: 'sara', owner: 'Sara', name: 'Bank card');
await ledger.recordIncome('ali', Money(300000), source: 'Allowance');
await ledger.transfer('ali', 'sara', Money(50000));
await ledger.recordExpense('sara', Money(20000), Category.books);
await app.close();
await counted;
print('Wallets opened: $wallets');
print('Transactions recorded: $transactions');
}Wallets opened: 2
Transactions recorded: 3The switch needs no default: LedgerEvent is sealed.
Practice Questions
Work through these in order — they run from beginner to application to challenge.
Beginner
- Write
example/welcome.dart: usingif case, printWelcome, <owner>!for everyWalletOpenedevent. Open wallets for Ali and Zayan. - Which three files does
BudgetApp.openread at the same time, and why is it safe to read them in parallel? - Put these in the order they happen at shutdown:
alertsDonecompletes, the controller is closed, the monitor'sawait forends, the log file is flushed.
Application
- Add a
WalletRenamedevent (withwalletandoldName) toledger_event.dart. What doesdart analyzereport in the project's own code? - With a non-broadcast controller, the stack trace names
BudgetMonitor.watch. Why the monitor, and not the activity log? - After
demo, you runincome sara 300 Scholarshipand thenspend bilal 10 food. Which lines are added todata/activity.log, and why?
Challenge
- Write an
async*functionStream<Money> runningSpent(Stream<LedgerEvent> events)that yields the total spent so far after each expense. Test it with Sara: Rs 2000 income, then Rs 450 and Rs 120 expenses. - Write a "big spender" alert: an
async*function that yields every single expense over Rs 1000. Test with Ali spending Rs 1200, Rs 1000 and Rs 300.
Practice Answers
Question 1 — Solution
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final app = BudgetApp.inMemory();
final done = app.ledger.events.forEach((event) {
if (event case WalletOpened(:final wallet)) {
print('Welcome, ${wallet.owner}!');
}
});
await app.ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
await app.ledger.openWallet(id: 'zayan', owner: 'Zayan', name: 'Cash');
await app.close();
await done;
}Welcome, Ali!
Welcome, Zayan!Question 2 — Solution
wallets.json, transactions.json and budgets.json. Each load() only reads its own file
into its own cache; none needs another's result.
Question 3 — Solution
- The controller is closed (
ledger.close()). - The monitor's
await forends. alertsDonecompletes.- The log file is flushed (
log.close()waits for itsforEach, then closes the sink).
Steps 2–3 and step 4 both start from step 1; bin/ waits for all of them.
Question 4 — Solution
error - lib/src/services/activity_log.dart:26:49 - The type 'LedgerEvent' isn't exhaustively matched by the switch cases since it doesn't match the pattern 'WalletRenamed()'. Try adding a wildcard pattern or cases that match 'WalletRenamed()'. - non_exhaustive_switch_expression_describe must say how to log the new event (your example/ files that switch over events
fail the same way). BudgetMonitor's if case simply ignores events it does not match.
Question 5 — Solution
BudgetApp.open creates the ActivityLog first, so it takes the only allowed subscription.
bin/ calls app.monitor.watch() later; its await for is the second listener and fails.
Question 6 — Solution
Only one line:
recorded t0007: 2026-10-17 Income from Scholarship Rs 300.00The spend bilal command fails with Error: No wallet with id "bilal". before anything is
saved, and the event is added only after a successful save.
Question 7 — Solution
import 'package:budget_tracker/budget_tracker.dart';
Stream<Money> runningSpent(Stream<LedgerEvent> events) async* {
var total = Money.zero;
await for (final event in events) {
if (event case TransactionRecorded(transaction: Expense(:final amount))) {
total = total + amount;
yield total;
}
}
}
Future<void> main() async {
final app = BudgetApp.inMemory(clock: () => DateTime(2026, 10, 17));
final ledger = app.ledger;
final done = runningSpent(ledger.events).forEach((total) {
print('spent so far: ${total.format()}');
});
await ledger.openWallet(id: 'sara', owner: 'Sara', name: 'Bank card');
await ledger.recordIncome('sara', Money(200000), source: 'Scholarship');
await ledger.recordExpense('sara', Money(45000), Category.books);
await ledger.recordExpense('sara', Money(12000), Category.food);
await app.close();
await done;
}spent so far: Rs 450.00
spent so far: Rs 570.00Question 8 — Solution
import 'package:budget_tracker/budget_tracker.dart';
final bigLimit = Money(100000);
Stream<Expense> bigSpends(Stream<LedgerEvent> events) async* {
await for (final event in events) {
if (event case TransactionRecorded(
transaction: Expense e,
) when e.amount > bigLimit) {
yield e;
}
}
}
Future<void> main() async {
final app = BudgetApp.inMemory(clock: () => DateTime(2026, 10, 17));
final ledger = app.ledger;
final done = bigSpends(ledger.events).forEach((e) {
print('! Big spender: ${e.walletId} spent ${e.amount.format()}');
});
await ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
await ledger.recordIncome('ali', Money(500000), source: 'Allowance');
await ledger.recordExpense('ali', Money(120000), Category.food);
await ledger.recordExpense('ali', Money(100000), Category.books);
await ledger.recordExpense('ali', Money(30000), Category.fun);
await app.close();
await done;
}! Big spender: ali spent Rs 1200.00Rs 1000 is not over Rs 1000, so only the first expense triggers. The when guard
(Lecture 080) keeps the condition inside the pattern.
Quick Revision
- Why is
BudgetApp.opena static method and not a constructor? - When is it safe to run futures in parallel with
Future.wait? - Why must the ledger's stream be broadcast?
- Why is the event added after
save, not before? - In which order does
bin/shut down, and why?
Key Takeaways
- Independent, read-only work runs in parallel with
Future.wait; results keep their order. - Constructors cannot be
async— use astatic Future<T>method to build an object from files. - A sealed
LedgerEventmakes every listener'sswitchcompiler-checked. - A private broadcast
StreamControllerlets many listeners hear one source. - Publish events only after the change is saved.
async*turns the event stream into a smaller stream (alerts);map+forEachturns it into log lines.- Broadcast streams don't remember: subscribe before the work.
- Shut down in a
finally: close the source, then await every listener's future.
What's Next?
Lecture 138 — Project Testing. The app now saves, reports, alerts and logs — and every bit
of it has been checked only by hand. Next we build a real test suite that mirrors lib/src:
models, JSON files in temporary folders, services with a fake clock, exceptions, and the
event streams from this lecture.