Watch the video (20 min)
On this page23 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. What a Service Is
  5. 2. One Sealed Family of Problems
  6. 3. The Ledger's Private Data
  7. 4. Opening and Finding Wallets
  8. 5. A Balance Is Calculated, Not Stored
  9. 6. Recording Money — With Checks First
  10. 7. History and the Private Helpers
  11. 8. The Shape of a Monthly Report
  12. 9. The Budget Service
  13. 10. Exporting the New Files
  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 134 — Project Services

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

Our models from Lecture 133 will happily describe Zayan spending Rs 5000 he doesn't have. A service owns the data and the rules, so a wrong move is refused with a clear, typed exception before it is ever recorded.

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 033–035, 049–050, 057, 068, 071, 076, 081–082, 085–086, 124 and 132–133:

  • named parameters, function values, maps, and private members with _
  • enhanced enums, typedef, records, and switch on a sealed class
  • custom exceptions, on Type catch (e), and ?? (throw ...)
  • the project's models: Money, Category, Wallet, Budget, Transaction

Connection With Previous Lectures

Lecture 133 gave the project correct, immutable models — but nothing stops misuse. Save this in the project as example/models_only.dart and run dart run example/models_only.dart:

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final spend = Expense(
    id: 't0001',
    walletId: 'zayan',
    amount: Money(500000),
    date: DateTime(2026, 10, 6),
    category: Category.fun,
  );
  print('Zayan: ${spend.effectOn('zayan').format()}');
}
output
Zayan: -Rs 5000.00

Zayan's empty wallet paid Rs 5000, and nothing remembers the transaction. Models describe money; they don't guard it. That job needs a new layer.


Learning Objectives

By the end of this lecture you will be able to:

  • Explain what a service is and why rules live there, not in models
  • Design a sealed exception family with useful fields and messages
  • Build LedgerService, which opens wallets and records income, expenses and transfers
  • Protect a service's data with private fields and List.unmodifiable
  • Inject a clock function so dates can be controlled
  • Build BudgetService and a MonthlyReport with a typedef record and an enhanced enum
  • Run the services demo and confirm dart analyze and dart test still pass

1. What a Service Is

A service is a class that owns some data and enforces the rules about it. Others ask it to act ("record this expense"); only the service touches the data.

Analogy — a bank teller. You never open the vault; you hand over a slip, and the teller checks your balance before recording the withdrawal or saying "insufficient funds".

This lecture adds four files:

FileWhat it holds
lib/src/errors.dartevery problem the app can report
lib/src/models/monthly_report.dartCategoryLine, BudgetLevel, MonthlyReport
lib/src/services/ledger_service.dartwallets, transactions, balances, history
lib/src/services/budget_service.dartbudgets and monthly reports

and changes lib/budget_tracker.dart (exports) and bin/budget_tracker.dart (a demo).


2. One Sealed Family of Problems

A service refuses bad input by throwing. Each problem gets its own class under a sealed base. Create lib/src/errors.dart:

dart
import 'models/money.dart';

/// Base type of every problem the budget tracker reports to the user.
sealed class BudgetException implements Exception {
  const BudgetException(this.message);

  /// A sentence the user can read.
  final String message;

  @override
  String toString() => message;
}

/// Thrown when a wallet id does not exist.
final class WalletNotFoundException extends BudgetException {
  WalletNotFoundException(this.walletId)
    : super('No wallet with id "$walletId".');

  final String walletId;
}

/// Thrown when a wallet id is already taken.
final class DuplicateWalletException extends BudgetException {
  DuplicateWalletException(this.walletId)
    : super('A wallet with id "$walletId" already exists.');

  final String walletId;
}

/// Thrown when an amount is not a positive number.
final class InvalidAmountException extends BudgetException {
  InvalidAmountException(this.input)
    : super('"$input" is not a valid amount. Use a positive number like 250.');

  final String input;
}

/// Thrown when a wallet does not hold enough money.
final class InsufficientFundsException extends BudgetException {
  InsufficientFundsException(this.walletId, this.balance, this.requested)
    : super(
        'Wallet "$walletId" has only ${balance.format()}, '
        'cannot take ${requested.format()}.',
      );

  final String walletId;
  final Money balance;
  final Money requested;
}

The message is written for the user, so a command line can print it unchanged. Details live in fields (balance, requested) that code can inspect. Because the base is sealed, one on BudgetException catch covers the whole family, and a switch over it is checked for completeness (Lecture 082).


3. The Ledger's Private Data

Create lib/src/services/ledger_service.dart. It starts with the data it owns:

dart
import '../errors.dart';
import '../models/category.dart';
import '../models/money.dart';
import '../models/transaction.dart';
import '../models/wallet.dart';

/// Keeps wallets and transactions and enforces the money rules.
class LedgerService {
  LedgerService({DateTime Function()? clock}) : _clock = clock ?? DateTime.now;

  final DateTime Function() _clock;
  final Map<String, Wallet> _wallets = {};
  final List<Transaction> _transactions = [];

  /// Every wallet, in the order they were opened.
  List<Wallet> get wallets => List.unmodifiable(_wallets.values);

  /// Every transaction, oldest first.
  List<Transaction> get transactions => List.unmodifiable(_transactions);

Only this file can change the private map and list; the getters hand out read-only lists. The clock is a function value (Lecture 035): normally the tear-off DateTime.now, but a caller may pass a fixed one — handy now, essential for tests in Lecture 138.


4. Opening and Finding Wallets

dart
  /// Opens a new wallet; the id must be unused.
  Wallet openWallet({
    required String id,
    required String owner,
    required String name,
    Money openingBalance = Money.zero,
  }) {
    if (_wallets.containsKey(id)) throw DuplicateWalletException(id);
    final wallet = Wallet(
      id: id,
      owner: owner,
      name: name,
      openingBalance: openingBalance,
    );
    _wallets[id] = wallet;
    return wallet;
  }

  /// Finds a wallet or throws [WalletNotFoundException].
  Wallet walletById(String id) =>
      _wallets[id] ?? (throw WalletNotFoundException(id));

walletById is the one place that turns "no such wallet" into an exception: the lookup gives a Wallet?, and ?? (throw ...) makes it a Wallet or stops (Lecture 085).


5. A Balance Is Calculated, Not Stored

dart
  /// The opening balance plus the effect of every transaction.
  Money balanceOf(String walletId) {
    var balance = walletById(walletId).openingBalance;
    for (final transaction in _transactions) {
      balance += transaction.effectOn(walletId);
    }
    return balance;
  }

There is no balance field to forget to update; effectOn from Lecture 133 does the work.


6. Recording Money — With Checks First

The three recording methods share a shape: check, build, add, return.

dart
  /// Records money coming into [walletId].
  Income recordIncome(
    String walletId,
    Money amount, {
    required String source,
    String note = '',
    DateTime? on,
  }) {
    walletById(walletId);
    _checkAmount(amount);
    final income = Income(
      id: _nextId(),
      walletId: walletId,
      amount: amount,
      date: on ?? _clock(),
      note: note,
      source: source,
    );
    _transactions.add(income);
    return income;
  }

  /// Records an expense; the wallet must hold enough money.
  Expense recordExpense(
    String walletId,
    Money amount,
    Category category, {
    String note = '',
    DateTime? on,
  }) {
    _checkAmount(amount);
    _checkFunds(walletId, amount);
    final expense = Expense(
      id: _nextId(),
      walletId: walletId,
      amount: amount,
      date: on ?? _clock(),
      note: note,
      category: category,
    );
    _transactions.add(expense);
    return expense;
  }

  /// Moves money between two wallets.
  Transfer transfer(
    String fromId,
    String toId,
    Money amount, {
    String note = '',
    DateTime? on,
  }) {
    walletById(toId);
    _checkAmount(amount);
    _checkFunds(fromId, amount);
    final transfer = Transfer(
      id: _nextId(),
      walletId: fromId,
      amount: amount,
      date: on ?? _clock(),
      note: note,
      toWalletId: toId,
    );
    _transactions.add(transfer);
    return transfer;
  }

Extras (note, on) are optional named parameters (Lecture 033); on ?? _clock() means "the date you gave, or now". A failed check stops the method before _transactions.add, so a refused transaction leaves no trace. recordExpense skips walletById because _checkFunds calls balanceOf, which looks the wallet up.


7. History and the Private Helpers

The class ends with the history and three private helpers:

dart
  /// The transactions that touch [walletId], oldest first.
  List<Transaction> historyOf(String walletId) {
    walletById(walletId);
    final result = <Transaction>[];
    for (final transaction in _transactions) {
      if (transaction.involves(walletId)) result.add(transaction);
    }
    return result;
  }

  String _nextId() =>
      't${(_transactions.length + 1).toString().padLeft(4, '0')}';

  void _checkAmount(Money amount) {
    if (!amount.isPositive) throw InvalidAmountException(amount.format());
  }

  void _checkFunds(String walletId, Money amount) {
    final balance = balanceOf(walletId);
    if (amount > balance) {
      throw InsufficientFundsException(walletId, balance, amount);
    }
  }
}

_nextId() produces t0001, t0002, …, so callers never invent ids. The loop in historyOf gets shorter with collection methods in Lecture 136.


8. The Shape of a Monthly Report

The budget service needs a result type. Create lib/src/models/monthly_report.dart:

dart
import 'category.dart';
import 'money.dart';

/// Spending in one category, with its budget limit if one is set.
typedef CategoryLine = ({Category category, Money spent, Money? limit});

/// How close spending is to its budget.
enum BudgetLevel {
  none('-'),
  ok('OK'),
  warning('Near limit'),
  over('OVER BUDGET');

  const BudgetLevel(this.label);

  /// The text shown in reports.
  final String label;

  /// Spending at or above this percentage of the limit is a warning.
  static const warningPercent = 80;

  /// Works out the level for [spent] against an optional [limit].
  static BudgetLevel of(Money spent, Money? limit) {
    if (limit == null) return none;
    if (spent > limit) return over;
    if (spent.cents * 100 >= limit.cents * warningPercent) return warning;
    return ok;
  }
}

/// The totals of one month.
final class MonthlyReport {
  const MonthlyReport({
    required this.month,
    required this.income,
    required this.spent,
    required this.lines,
  });

  /// Any day in the month the report covers.
  final DateTime month;
  final Money income;
  final Money spent;
  final List<CategoryLine> lines;

  /// What is left after spending.
  Money get net => income - spent;
}

CategoryLine is a named record type (Lectures 071, 076); limit is Money? because a category may have no budget. BudgetLevel.of compares whole cents, so exactly 80 % involves no double rounding.


9. The Budget Service

Create lib/src/services/budget_service.dart:

dart
import '../extensions/date_extensions.dart';
import '../models/budget.dart';
import '../models/category.dart';
import '../models/monthly_report.dart';
import '../models/money.dart';
import '../models/transaction.dart';
import 'ledger_service.dart';

/// Keeps monthly budgets and builds monthly reports.
class BudgetService {
  BudgetService(this._ledger);

  final LedgerService _ledger;
  final Map<Category, Budget> _budgets = {};

  /// Every budget that has been set.
  List<Budget> get budgets => List.unmodifiable(_budgets.values);

  /// Sets (or replaces) the monthly limit of [category].
  Budget setBudget(Category category, Money limit) {
    final budget = Budget(category: category, monthlyLimit: limit);
    _budgets[category] = budget;
    return budget;
  }

  /// Totals for the month that contains [month], optionally for one wallet.
  MonthlyReport monthlyReport(DateTime month, {String? walletId}) {
    var income = Money.zero;
    var spent = Money.zero;
    final spentByCategory = <Category, Money>{};

    for (final transaction in _ledger.transactions) {
      if (!transaction.date.isSameMonth(month)) continue;
      if (walletId != null && transaction.walletId != walletId) continue;
      switch (transaction) {
        case Income(:final amount):
          income += amount;
        case Expense(:final amount, :final category):
          spent += amount;
          spentByCategory[category] =
              (spentByCategory[category] ?? Money.zero) + amount;
        case Transfer():
          break; // Moving money is neither income nor spending.
      }
    }

    final lines = <CategoryLine>[];
    for (final category in Category.values) {
      final categorySpent = spentByCategory[category];
      final limit = _budgets[category]?.monthlyLimit;
      if (categorySpent == null && limit == null) continue;
      lines.add((
        category: category,
        spent: categorySpent ?? Money.zero,
        limit: limit,
      ));
    }

    return MonthlyReport(
      month: month,
      income: income,
      spent: spent,
      lines: lines,
    );
  }
}

BudgetService receives the LedgerService and reads only its public transactions — one service using another. Budgets are keyed by Category, so setBudget replaces; spentByCategory is a map used as a counter (Lecture 050). case Transfer(): break; states a rule out loud: moving money is not spending.


10. Exporting the New Files

Open lib/budget_tracker.dart and make its export list read:

dart
/// Student Budget Tracker: wallets, transactions, budgets and reports.
library;

export 'src/app_info.dart';
export 'src/errors.dart';
export 'src/extensions/date_extensions.dart';
export 'src/models/budget.dart';
export 'src/models/category.dart';
export 'src/models/money.dart';
export 'src/models/monthly_report.dart';
export 'src/models/transaction.dart';
export 'src/models/wallet.dart';
export 'src/services/budget_service.dart';
export 'src/services/ledger_service.dart';

11. A Real-World Example

Replace bin/budget_tracker.dart with the services demo. The four mistakes are a list of (label, closure) records, tried in one loop:

dart
import 'package:budget_tracker/budget_tracker.dart';

void main(List<String> arguments) {
  print('$appName v$appVersion - services demo\n');

  final ledger = LedgerService();
  final budgets = BudgetService(ledger);

  ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
  ledger.openWallet(
    id: 'sara',
    owner: 'Sara',
    name: 'Bank card',
    openingBalance: Money(200000),
  );
  ledger.openWallet(id: 'zayan', owner: 'Zayan', name: 'Cash');

  final oct = DateTime(2026, 10);
  ledger.recordIncome('ali', Money(500000), source: 'Allowance', on: oct);
  ledger.recordIncome('zayan', Money(150000), source: 'Tutoring', on: oct);
  ledger.recordExpense(
    'ali',
    Money(120000),
    Category.food,
    note: 'groceries',
    on: DateTime(2026, 10, 3),
  );
  ledger.recordExpense(
    'sara',
    Money(80000),
    Category.books,
    on: DateTime(2026, 10, 4),
  );
  ledger.transfer(
    'ali',
    'zayan',
    Money(50000),
    note: 'bus pass share',
    on: DateTime(2026, 10, 5),
  );
  ledger.recordExpense(
    'zayan',
    Money(60000),
    Category.transport,
    on: DateTime(2026, 10, 6),
  );

  budgets.setBudget(Category.food, Money(150000));
  budgets.setBudget(Category.transport, Money(50000));

  print('Balances:');
  for (final wallet in ledger.wallets) {
    print(
      '  ${wallet.owner.padRight(6)} ${ledger.balanceOf(wallet.id).format()}',
    );
  }

  print('\nAli\'s history:');
  for (final transaction in ledger.historyOf('ali')) {
    print('  ${transaction.describe()}');
  }

  print('\nMistakes the services refuse:');
  final attempts = <(String, void Function())>[
    (
      'Zayan spends Rs 5000',
      () {
        ledger.recordExpense('zayan', Money(500000), Category.fun);
      },
    ),
    (
      'Spend from "bilal"',
      () {
        ledger.recordExpense('bilal', Money(100), Category.food);
      },
    ),
    (
      'Income of Rs 0',
      () {
        ledger.recordIncome('ali', Money.zero, source: 'Nothing');
      },
    ),
    (
      'Open "ali" again',
      () {
        ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Second');
      },
    ),
  ];
  for (final (label, attempt) in attempts) {
    try {
      attempt();
    } on BudgetException catch (e) {
      print('  $label -> ${e.runtimeType}: $e');
    }
  }

  final report = budgets.monthlyReport(oct);
  print('\nReport for ${report.month.monthLabel}:');
  print(
    '  Income ${report.income.format()}, spent ${report.spent.format()}, '
    'net ${report.net.format()}',
  );
  for (final line in report.lines) {
    final level = BudgetLevel.of(line.spent, line.limit);
    final limit = line.limit?.format() ?? 'no limit';
    print(
      '  ${line.category.label.padRight(10)} ${line.spent.format()} / '
      '$limit  ${level.label}',
    );
  }
}

There is no command-line interface yet, so plain dart run starts the default executable. Every transaction has a fixed October 2026 date, so your output matches on any day:

output
$ dart run
Student Budget Tracker v0.1.0 - services demo

Balances:
  Ali    Rs 3300.00
  Sara   Rs 1200.00
  Zayan  Rs 1400.00

Ali's history:
  2026-10-01  Income from Allowance  Rs 5000.00
  2026-10-03  Food expense (groceries)  Rs 1200.00
  2026-10-05  Transfer ali -> zayan (bus pass share)  Rs 500.00

Mistakes the services refuse:
  Zayan spends Rs 5000 -> InsufficientFundsException: Wallet "zayan" has only Rs 1400.00, cannot take Rs 5000.00.
  Spend from "bilal" -> WalletNotFoundException: No wallet with id "bilal".
  Income of Rs 0 -> InvalidAmountException: "Rs 0.00" is not a valid amount. Use a positive number like 250.
  Open "ali" again -> DuplicateWalletException: A wallet with id "ali" already exists.

Report for 2026-10:
  Income Rs 6500.00, spent Rs 2600.00, net Rs 3900.00
  Food       Rs 1200.00 / Rs 1500.00  Near limit
  Transport  Rs 600.00 / Rs 500.00  OVER BUDGET
  Books      Rs 800.00 / no limit  -

The transfer counts as neither income nor spending; food at 80 % is "Near limit". Check the project:

output
$ dart analyze
Analyzing budget_tracker...
No issues found!

$ dart test
00:00 +1: All tests passed!

The data still lives in memory inside the services, and vanishes when the program ends.


Code Examples

Save each program in the project's example/ folder; run it with dart run example/<file>.dart.

Example 1 — Balances (example/balances.dart)

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final ledger = LedgerService();
  ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
  ledger.openWallet(
    id: 'sara',
    owner: 'Sara',
    name: 'Bank card',
    openingBalance: Money(200000),
  );
  ledger.recordIncome('ali', Money(300000), source: 'Allowance');

  for (final wallet in ledger.wallets) {
    print('${wallet.owner}: ${ledger.balanceOf(wallet.id).format()}');
  }
}
output
Ali: Rs 3000.00
Sara: Rs 2000.00

Example 2 — Reading an Exception's Fields (example/insufficient.dart)

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final ledger = LedgerService();
  ledger.openWallet(
    id: 'zayan',
    owner: 'Zayan',
    name: 'Cash',
    openingBalance: Money(140000),
  );

  try {
    ledger.recordExpense('zayan', Money(500000), Category.fun);
  } on InsufficientFundsException catch (e) {
    print('Wallet:    ${e.walletId}');
    print('Balance:   ${e.balance.format()}');
    print('Requested: ${e.requested.format()}');
  }
  print('Transactions recorded: ${ledger.transactions.length}');
}
output
Wallet:    zayan
Balance:   Rs 1400.00
Requested: Rs 5000.00
Transactions recorded: 0

Example 3 — A Switch Over the Sealed Family (example/hints.dart)

dart
import 'package:budget_tracker/budget_tracker.dart';

String hint(BudgetException e) => switch (e) {
  WalletNotFoundException() => 'Check the wallet id.',
  DuplicateWalletException() => 'Pick another id.',
  InvalidAmountException() => 'Type a positive amount.',
  InsufficientFundsException() => 'Add income first.',
};

void main() {
  final ledger = LedgerService();
  ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');

  final attempts = <void Function()>[
    () => ledger.recordExpense('bilal', Money(1000), Category.food),
    () => ledger.recordIncome('ali', Money(-500), source: 'Oops'),
    () => ledger.recordExpense('ali', Money(1000), Category.food),
  ];
  for (final attempt in attempts) {
    try {
      attempt();
    } on BudgetException catch (e) {
      print('$e ${hint(e)}');
    }
  }
}
output
No wallet with id "bilal". Check the wallet id.
"-Rs 5.00" is not a valid amount. Use a positive number like 250. Type a positive amount.
Wallet "ali" has only Rs 0.00, cannot take Rs 10.00. Add income first.

No _ case is needed: the family is sealed.

Example 4 — A Fixed Clock (example/clock.dart)

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final ledger = LedgerService(clock: () => DateTime(2026, 10, 15));
  ledger.openWallet(id: 'sara', owner: 'Sara', name: 'Bank card');

  final pay = ledger.recordIncome('sara', Money(250000), source: 'Scholarship');
  final book = ledger.recordExpense(
    'sara',
    Money(45000),
    Category.books,
    note: 'Dart book',
  );
  final early = ledger.recordExpense(
    'sara',
    Money(8000),
    Category.transport,
    on: DateTime(2026, 10, 2),
  );

  for (final t in [pay, book, early]) {
    print('${t.id}  ${t.describe()}');
  }
}
output
t0001  2026-10-15  Income from Scholarship  Rs 2500.00
t0002  2026-10-15  Books expense (Dart book)  Rs 450.00
t0003  2026-10-02  Transport expense  Rs 80.00

Example 5 — One Transfer, Two Histories (example/history.dart)

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final ledger = LedgerService(clock: () => DateTime(2026, 10, 5));
  ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
  ledger.openWallet(id: 'zayan', owner: 'Zayan', name: 'Cash');
  ledger.recordIncome('ali', Money(100000), source: 'Allowance');
  ledger.transfer('ali', 'zayan', Money(30000), note: 'bus pass');

  for (final id in ['ali', 'zayan']) {
    print('$id:');
    for (final t in ledger.historyOf(id)) {
      print('  ${t.describe()}  -> ${t.effectOn(id).format()}');
    }
  }
}
output
ali:
  2026-10-05  Income from Allowance  Rs 1000.00  -> Rs 1000.00
  2026-10-05  Transfer ali -> zayan (bus pass)  Rs 300.00  -> -Rs 300.00
zayan:
  2026-10-05  Transfer ali -> zayan (bus pass)  Rs 300.00  -> Rs 300.00

The transfer appears in both histories, with opposite effects.

Example 6 — Budget Levels at the Edges (example/levels.dart)

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final limit = Money(100000);
  for (final spent in [50000, 79999, 80000, 100000, 120000]) {
    final level = BudgetLevel.of(Money(spent), limit);
    print('${Money(spent).format().padLeft(11)}  ${level.label}');
  }
  print('No limit: ${BudgetLevel.of(Money(5000), null).label}');
}
output
  Rs 500.00  OK
  Rs 799.99  OK
  Rs 800.00  Near limit
 Rs 1000.00  Near limit
 Rs 1200.00  OVER BUDGET
No limit: -

Example 7 — Read-Only Lists (example/read_only.dart)

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final ledger = LedgerService();
  ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');

  final fake = Income(
    id: 'x',
    walletId: 'ali',
    amount: Money(99999900),
    date: DateTime(2026, 10, 1),
    source: 'Wishful thinking',
  );
  try {
    ledger.transactions.add(fake);
  } on UnsupportedError catch (e) {
    print(e);
  }
  print('Balance: ${ledger.balanceOf('ali').format()}');
}
output
Unsupported operation: Cannot add to an unmodifiable list
Balance: Rs 0.00

Example 8 — A Report for One Wallet (example/wallet_report.dart)

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final ledger = LedgerService(clock: () => DateTime(2026, 10, 10));
  final budgets = BudgetService(ledger);
  ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
  ledger.openWallet(id: 'sara', owner: 'Sara', name: 'Bank card');
  ledger.recordIncome('ali', Money(200000), source: 'Allowance');
  ledger.recordIncome('sara', Money(300000), source: 'Scholarship');
  ledger.recordExpense('ali', Money(60000), Category.food);
  ledger.recordExpense('sara', Money(90000), Category.food);
  ledger.transfer('sara', 'ali', Money(50000));
  budgets.setBudget(Category.food, Money(100000));

  for (final walletId in [null, 'ali', 'sara']) {
    final report = budgets.monthlyReport(
      DateTime(2026, 10),
      walletId: walletId,
    );
    final food = report.lines.first;
    final level = BudgetLevel.of(food.spent, food.limit);
    print(
      '${walletId ?? 'all'}: income ${report.income.format()}, '
      'food ${food.spent.format()} ${level.label}',
    );
  }
}
output
all: income Rs 5000.00, food Rs 1500.00 OVER BUDGET
ali: income Rs 2000.00, food Rs 600.00 OK
sara: income Rs 3000.00, food Rs 900.00 Near limit

Together they are over the food budget; each alone is not.


Common Beginner Mistakes

1. Writing ?? throw without parentheses

dart
// Wallet walletById(String id) =>
//     _wallets[id] ?? throw WalletNotFoundException(id); // ✗
output
Error: 'throw' can't be used as an identifier because it's a keyword.

Write ?? (throw WalletNotFoundException(id)).

2. Forgetting the Transfer case

Without case Transfer(): break; in monthlyReport:

output
Error: The type 'Transaction' is not exhaustively matched by the switch cases since it doesn't match 'Transfer()'.

Keep the empty case; it documents the rule.

3. Extending the sealed exception from another file

dart
// In example/too_expensive.dart:
// class TooExpensiveException extends BudgetException { ... } // ✗
output
Error: The class 'BudgetException' can't be extended, implemented, or mixed in outside of its library because it's a sealed class.

New problems go into lib/src/errors.dart itself.


Best Practices

  • Put rules that need other data (balances, existing ids) in services, not models.
  • Check everything before changing anything.
  • Give each failure its own exception class, under one sealed base, with a user-facing message.
  • Derive balances instead of storing them; expose read-only views; inject the clock.

Mini Challenge

In example/replace_budget.dart, set food to Rs 1500 and fun to Rs 500, then food to Rs 1200. Print budgets.budgets, then October 2026's report lines, destructuring each record.

Try it yourself before opening the solution.

Show solution
dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final budgets = BudgetService(LedgerService());
  budgets.setBudget(Category.food, Money(150000));
  budgets.setBudget(Category.fun, Money(50000));
  budgets.setBudget(Category.food, Money(120000));
  print(budgets.budgets);

  final report = budgets.monthlyReport(DateTime(2026, 10));
  for (final (:category, :spent, :limit) in report.lines) {
    print('${category.label}: ${spent.format()} of ${limit?.format()}');
  }
}
output
[Budget(Food: Rs 1200.00), Budget(Fun: Rs 500.00)]
Food: Rs 0.00 of Rs 1200.00
Fun: Rs 0.00 of Rs 500.00

How it works

  1. The second food budget replaces the first, keeping food's position in the map.
  2. A category with a limit gets a report line even with nothing spent.

Practice Questions

Save each answer in example/. They run from beginner to challenge.

Beginner

  1. Open Ali's wallet with an opening balance of Rs 1000, record Rs 500 income from 'Birthday', and print Ali: Rs 1500.00.
  2. Ali records income of Money(-2500) from 'Refund'. Catch it with on BudgetException and print the exception. What is printed?
  3. With no wallets opened, call historyOf('bilal'). Catch only WalletNotFoundException and print Unknown wallet: bilal using the exception's field.

Application

  1. Use a clock fixed at 2026-10-20. Zayan earns Rs 1500 from 'Tutoring' and spends Rs 200 on food with the note 'chai'. Print his history, then his balance.
  2. With no wallets opened, call recordExpense('bilal', Money.zero, Category.food). Which exception is thrown — and why that one, when the wallet doesn't exist either?
  3. Sara (opening balance Rs 2000) spends Rs 400 on food; the food budget is Rs 500. Print each line of the October 2026 report as Food: <level label>.

Challenge

  1. Add a rule: a transfer from a wallet to itself throws a new SameWalletException. Where must the class go, and what happens to Example 3?
  2. Add Wallet renameWallet(String id, String newName) to LedgerService, using copyWith.

For 7 and 8, work on a copy of the project or undo your changes afterwards: Lecture 135 continues from the code exactly as shown in this lecture.


Practice Answers

Question 1 — Solution

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final ledger = LedgerService();
  ledger.openWallet(
    id: 'ali',
    owner: 'Ali',
    name: 'Pocket money',
    openingBalance: Money(100000),
  );
  ledger.recordIncome('ali', Money(50000), source: 'Birthday');
  print('Ali: ${ledger.balanceOf('ali').format()}');
}
output
Ali: Rs 1500.00

Question 2 — Solution

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final ledger = LedgerService();
  ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
  try {
    ledger.recordIncome('ali', Money(-2500), source: 'Refund');
  } on BudgetException catch (e) {
    print(e);
  }
}
output
"-Rs 25.00" is not a valid amount. Use a positive number like 250.

Question 3 — Solution

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final ledger = LedgerService();
  try {
    ledger.historyOf('bilal');
  } on WalletNotFoundException catch (e) {
    print('Unknown wallet: ${e.walletId}');
  }
}
output
Unknown wallet: bilal

Question 4 — Solution

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final ledger = LedgerService(clock: () => DateTime(2026, 10, 20));
  ledger.openWallet(id: 'zayan', owner: 'Zayan', name: 'Cash');
  ledger.recordIncome('zayan', Money(150000), source: 'Tutoring');
  ledger.recordExpense('zayan', Money(20000), Category.food, note: 'chai');
  for (final t in ledger.historyOf('zayan')) {
    print(t.describe());
  }
  print('Balance: ${ledger.balanceOf('zayan').format()}');
}
output
2026-10-20  Income from Tutoring  Rs 1500.00
2026-10-20  Food expense (chai)  Rs 200.00
Balance: Rs 1300.00

Question 5 — Solution

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final ledger = LedgerService();
  try {
    ledger.recordExpense('bilal', Money.zero, Category.food);
  } on BudgetException catch (e) {
    print('${e.runtimeType}: $e');
  }
}
output
InvalidAmountException: "Rs 0.00" is not a valid amount. Use a positive number like 250.

_checkAmount runs before _checkFunds looks up the wallet; the first failing check wins.

Question 6 — Solution

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final ledger = LedgerService(clock: () => DateTime(2026, 10, 12));
  final budgets = BudgetService(ledger);
  ledger.openWallet(
    id: 'sara',
    owner: 'Sara',
    name: 'Bank card',
    openingBalance: Money(200000),
  );
  ledger.recordExpense('sara', Money(40000), Category.food);
  budgets.setBudget(Category.food, Money(50000));

  final report = budgets.monthlyReport(DateTime(2026, 10));
  for (final line in report.lines) {
    final level = BudgetLevel.of(line.spent, line.limit);
    print('${line.category.label}: ${level.label}');
  }
}
output
Food: Near limit

Exactly 80 %.

Question 7 — Solution

The class must go in lib/src/errors.dart — a sealed class can only be extended in its own file. Add at the end of that file:

dart
/// Thrown when a transfer starts and ends in the same wallet.
final class SameWalletException extends BudgetException {
  SameWalletException(this.walletId)
    : super('Cannot transfer from "$walletId" to itself.');

  final String walletId;
}

and make it the first line of transfer's body in ledger_service.dart:

dart
    if (fromId == toId) throw SameWalletException(fromId);

Now ledger.transfer('ali', 'ali', Money(5000)) throws, and printed with its runtimeType shows:

output
SameWalletException: Cannot transfer from "ali" to itself.

Example 3's hint switch stops compiling — Dart lists the new case to handle:

output
Error: The type 'BudgetException' is not exhaustively matched by the switch cases since it doesn't match 'SameWalletException()'.

Question 8 — Solution

Add to LedgerService:

dart
  /// Gives the wallet [id] a new name.
  Wallet renameWallet(String id, String newName) {
    final renamed = walletById(id).copyWith(name: newName);
    _wallets[id] = renamed;
    return renamed;
  }
dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final ledger = LedgerService();
  ledger.openWallet(id: 'sara', owner: 'Sara', name: 'Bank card');
  ledger.renameWallet('sara', 'Student card');
  print(ledger.wallets);
}
output
[Wallet(sara, Sara, Student card)]

Wallet is immutable, so a changed copy replaces it in the private map.


Quick Revision

  1. What is the difference between a model and a service?
  2. Why is balanceOf calculated instead of stored?
  3. What does List.unmodifiable protect against?
  4. Why does LedgerService take a clock parameter?
  5. Is spending exactly 100 % of a budget warning or over?

Key Takeaways

  • A service owns its data privately and enforces the rules; models just describe values.
  • A sealed exception family gives each failure a type, fields and a user-readable message — catch one, or all with on BudgetException.
  • Check first, then change. Derive balances instead of storing them.
  • Getters return List.unmodifiable views; private helpers stay behind _.
  • MonthlyReport uses a typedef record and an enhanced enum; BudgetService builds on LedgerService through its public API.

What's Next?

Lecture 135 — Project Repository. Our services are welded to their private maps, and everything disappears when the program ends. Next we separate rules from storage: a generic Repository interface with in-memory and JSON-file versions, async services, and the first real command-line interface that saves to disk.


Video lecture

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