On this page23 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. What a Service Is
- 2. One Sealed Family of Problems
- 3. The Ledger's Private Data
- 4. Opening and Finding Wallets
- 5. A Balance Is Calculated, Not Stored
- 6. Recording Money — With Checks First
- 7. History and the Private Helpers
- 8. The Shape of a Monthly Report
- 9. The Budget Service
- 10. Exporting the New Files
- 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 134 — Project Services
| Category | 23 — Final Dart Project |
| Lecture | 134 of 140 |
| Estimated video length | 20 min |
| Target | Dart 3.12.2 |
| Level | Advanced |
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, andswitchon 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:
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()}');
}Zayan: -Rs 5000.00Zayan'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
BudgetServiceand aMonthlyReportwith atypedefrecord and an enhanced enum - Run the services demo and confirm
dart analyzeanddart teststill 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:
| File | What it holds |
|---|---|
lib/src/errors.dart | every problem the app can report |
lib/src/models/monthly_report.dart | CategoryLine, BudgetLevel, MonthlyReport |
lib/src/services/ledger_service.dart | wallets, transactions, balances, history |
lib/src/services/budget_service.dart | budgets 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:
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:
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
/// 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
/// 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.
/// 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:
/// 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:
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:
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:
/// 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:
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:
$ 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:
$ 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)
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()}');
}
}Ali: Rs 3000.00
Sara: Rs 2000.00Example 2 — Reading an Exception's Fields (example/insufficient.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}');
}Wallet: zayan
Balance: Rs 1400.00
Requested: Rs 5000.00
Transactions recorded: 0Example 3 — A Switch Over the Sealed Family (example/hints.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)}');
}
}
}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)
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()}');
}
}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.00Example 5 — One Transfer, Two Histories (example/history.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()}');
}
}
}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.00The transfer appears in both histories, with opposite effects.
Example 6 — Budget Levels at the Edges (example/levels.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}');
} 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)
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()}');
}Unsupported operation: Cannot add to an unmodifiable list
Balance: Rs 0.00Example 8 — A Report for One Wallet (example/wallet_report.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}',
);
}
}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 limitTogether they are over the food budget; each alone is not.
Common Beginner Mistakes
1. Writing ?? throw without parentheses
// Wallet walletById(String id) =>
// _wallets[id] ?? throw WalletNotFoundException(id); // ✗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:
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
// In example/too_expensive.dart:
// class TooExpensiveException extends BudgetException { ... } // ✗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
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()}');
}
}[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.00How it works
- The second food budget replaces the first, keeping food's position in the map.
- 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
- Open Ali's wallet with an opening balance of Rs 1000, record Rs 500 income from
'Birthday', and printAli: Rs 1500.00. - Ali records income of
Money(-2500)from'Refund'. Catch it withon BudgetExceptionand print the exception. What is printed? - With no wallets opened, call
historyOf('bilal'). Catch onlyWalletNotFoundExceptionand printUnknown wallet: bilalusing the exception's field.
Application
- 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. - 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? - 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
- 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? - Add
Wallet renameWallet(String id, String newName)toLedgerService, usingcopyWith.
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
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()}');
}Ali: Rs 1500.00Question 2 — Solution
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);
}
}"-Rs 25.00" is not a valid amount. Use a positive number like 250.Question 3 — Solution
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}');
}
}Unknown wallet: bilalQuestion 4 — Solution
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()}');
}2026-10-20 Income from Tutoring Rs 1500.00
2026-10-20 Food expense (chai) Rs 200.00
Balance: Rs 1300.00Question 5 — Solution
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');
}
}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
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}');
}
}Food: Near limitExactly 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:
/// 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:
if (fromId == toId) throw SameWalletException(fromId);Now ledger.transfer('ali', 'ali', Money(5000)) throws, and printed with its
runtimeType shows:
SameWalletException: Cannot transfer from "ali" to itself.Example 3's hint switch stops compiling — Dart lists the new case to handle:
Error: The type 'BudgetException' is not exhaustively matched by the switch cases since it doesn't match 'SameWalletException()'.Question 8 — Solution
Add to LedgerService:
/// 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;
}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);
}[Wallet(sara, Sara, Student card)]Wallet is immutable, so a changed copy replaces it in the private map.
Quick Revision
- What is the difference between a model and a service?
- Why is
balanceOfcalculated instead of stored? - What does
List.unmodifiableprotect against? - Why does
LedgerServicetake aclockparameter? - Is spending exactly 100 % of a budget
warningorover?
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.unmodifiableviews; private helpers stay behind_. MonthlyReportuses atypedefrecord and an enhanced enum;BudgetServicebuilds onLedgerServicethrough 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.