Watch the video (20 min)
On this page23 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. The Async Map of the App
  5. 2. Loading Three Files at Once
  6. 3. Parallel Balances
  7. 4. Events as a Sealed Class
  8. 5. Publishing Events from the Ledger
  9. 6. Why the Stream Must Be Broadcast
  10. 7. Listener One — the Budget Monitor
  11. 8. Listener Two — the Activity Log
  12. 9. Wiring and Closing in BudgetApp
  13. 10. The CLI: Subscribe, Then Shut Down in Order
  14. 11. A Real-World Example
  15. Code Examples
  16. Common Beginner Mistakes
  17. Best Practices
  18. Mini Challenge
  19. Practice Questions
  20. Practice Answers
  21. Quick Revision
  22. Key Takeaways
  23. What's Next?

Lecture 137 — Async Architecture

Category23 — Final Dart Project
Lecture137 of 140
Estimated video length20 min
TargetDart 3.12.2
LevelAdvanced

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 every await is a gap where other code runs
  • streams, await for, StreamController, broadcast streams, map/where/forEach
  • async* generators; sealed classes, exhaustive switch, and object patterns in if case
  • writing files with openWrite and FileMode.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. demo puts 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.wait and explain why it is safe
  • Model app events as a sealed class hierarchy
  • Publish events from a private broadcast StreamController behind a Stream getter
  • 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:

output
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 exits

There 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):

dart
  /// 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:

dart
  /// 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:

dart
  /// 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:

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):

dart
  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:

dart
    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:

output
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):

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:

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:

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):

dart
/// Wires repositories, services and listeners together.
class BudgetApp {
  BudgetApp._(this.ledger, this.budgets, {this.log})
    : monitor = BudgetMonitor(ledger, budgets);
dart
  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:

dart
  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:

dart
  } on BudgetException catch (e) {
    stderr.writeln('Error: $e');
    exitCode = 1;
  } finally {
    await app.close();
    await alertsDone;
  }

The order matters:

  1. app.close() closes the controller → the event stream ends.
  2. The monitor's await for finishes → the alert stream ends → alertsDone completes.
  3. log.close() waited for its forEach, 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):

output
$ 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.00

Books 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

dart
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');
}
output
event: opened ali
commands done
event: saved t0001
listener done

How it works

  1. Events arrive asynchronously: the listener runs only when main pauses at an await, so saved t0001 prints after commands done.
  2. await app.close() lets the last event through; await printed waits for the listener.

Example 2 — Loading in Parallel

It reads the data/ folder left by the session in section 11.

dart
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()}');
}
output
wallets:      3
transactions: 9
budgets:      3

Example 3 — Budget Alerts in Memory

dart
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;
}
output
spent Rs 300.00
spent Rs 150.00
ALERT Transport: Near limit
spent Rs 100.00
ALERT Transport: OVER BUDGET

How it works

  1. Rs 300 of Rs 500 is 60 % — no alert. Rs 450 is 90 % — "Near limit". Rs 550 is over.
  2. Each alert prints after its expense's spent line: the monitor still awaits lineFor while main carries on.

Example 4 — Two Listeners on One Stream

dart
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;
}
output
broadcast: true
listener 2 got WalletOpened
listener 2 got TransactionRecorded
listener 1 counted 2 events

length is a Future that completes only when the stream ends — after close().

Example 5 — Late Listeners Miss Events

dart
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}');
  }
}
output
saw zayan

Broadcast 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

dart
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;
}
output
ali -> zayan: Rs 500.00
zayan -> ali: Rs 200.00

The same shape as BudgetMonitor.watch(): read every event, yield only some.

Example 7 — Counting Zayan's Expenses with where

dart
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');
}
output
Zayan made 2 expenses

Example 8 — The Activity Log on Its Own

dart
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());
}
output
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

dart
// final _events = StreamController<LedgerEvent>(); // ✗ two listeners
output
Bad 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;:

output
$ 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.00

No 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

dart
  } 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

dart
// factory BudgetApp.open(String dataDir) async { ... } // ✗
output
error - src/budget_app.dart:39:6 - Factory bodies can't use 'async', 'async*', or 'sync*'. - non_sync_factory

Constructors return the object at once. Use a static Future<BudgetApp> method.


Best Practices

  • Use Future.wait for independent work that only reads; keep read-check-write steps sequential.
  • Keep the StreamController private; expose only a Stream getter.
  • 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 switch is 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.

output
Wallets opened: 2
Transactions recorded: 3

Try it yourself before opening the solution.

Show solution
dart
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');
}
output
Wallets opened: 2
Transactions recorded: 3

The switch needs no default: LedgerEvent is sealed.


Practice Questions

Work through these in order — they run from beginner to application to challenge.

Beginner

  1. Write example/welcome.dart: using if case, print Welcome, <owner>! for every WalletOpened event. Open wallets for Ali and Zayan.
  2. Which three files does BudgetApp.open read at the same time, and why is it safe to read them in parallel?
  3. Put these in the order they happen at shutdown: alertsDone completes, the controller is closed, the monitor's await for ends, the log file is flushed.

Application

  1. Add a WalletRenamed event (with wallet and oldName) to ledger_event.dart. What does dart analyze report in the project's own code?
  2. With a non-broadcast controller, the stack trace names BudgetMonitor.watch. Why the monitor, and not the activity log?
  3. After demo, you run income sara 300 Scholarship and then spend bilal 10 food. Which lines are added to data/activity.log, and why?

Challenge

  1. Write an async* function Stream<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.
  2. 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

dart
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;
}
output
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

  1. The controller is closed (ledger.close()).
  2. The monitor's await for ends.
  3. alertsDone completes.
  4. The log file is flushed (log.close() waits for its forEach, then closes the sink).

Steps 2–3 and step 4 both start from step 1; bin/ waits for all of them.

Question 4 — Solution

output
  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:

output
recorded t0007: 2026-10-17  Income from Scholarship  Rs 300.00

The 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

dart
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;
}
output
spent so far: Rs 450.00
spent so far: Rs 570.00

Question 8 — Solution

dart
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;
}
output
! Big spender: ali spent Rs 1200.00

Rs 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

  1. Why is BudgetApp.open a static method and not a constructor?
  2. When is it safe to run futures in parallel with Future.wait?
  3. Why must the ledger's stream be broadcast?
  4. Why is the event added after save, not before?
  5. 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 a static Future<T> method to build an object from files.
  • A sealed LedgerEvent makes every listener's switch compiler-checked.
  • A private broadcast StreamController lets many listeners hear one source.
  • Publish events only after the change is saved.
  • async* turns the event stream into a smaller stream (alerts); map + forEach turns 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.


Video lecture

Subscribe
Placeholder video — the real recording isn't linked yet.