On this page23 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. What Is Clean Code?
- 2. The Messy Checkout
- 3. Small, Single-Purpose Functions
- 4. Meaningful Names
- 5. Guard Clauses Instead of Deep Nesting
- 6. Named Constants Instead of Magic Numbers
- 7. Replacing Boolean Flags
- 8. DRY — and When Not to Abstract
- 9. Comments That Explain Why
- 10. Consistent Error Handling
- 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 123 — Writing Clean Dart
| Category | 21 — Professional Dart Development |
| Lecture | 123 of 140 |
| Estimated video length | 10 min |
| Target | Dart 3.12.2 |
| Level | Advanced |
A program can pass every lint and every test and still be painful to change. Clean code is code the next reader understands at a glance — and we get there by refactoring, step by step, without changing a single line of output.
Contents
Prerequisites · Connection · Objectives · Lesson · Code Examples · Common Mistakes · Best Practices · Mini Challenge · Practice Questions · Answers · Takeaways
Every output and error message in this lecture is real. Each was produced by running the code shown on Dart 3.12.2. Every "before" and "after" was run, and they print the same thing.
Prerequisites
Students should already understand, from Lectures 001–122:
const(006), switch expressions (025), named parameters (033), enums (067)- one job per function, good names and guard clauses (038)
throwandon ... catch(084–085), Effective Dart (117),expect(119)
Connection With Previous Lectures
Lecture 117 made our code lint-clean; Lectures 118–122 proved it gives the right answers.
But neither notices that calc(p, q, true, c) is unreadable, or that changing the delivery
fee means hunting for 150 everywhere. How do we
make working code easy to read and safe to change — without breaking it?
Learning Objectives
By the end of this lecture you will be able to:
- Explain refactoring and why behaviour must not change
- Choose meaningful names
- Split a long function into small, single-purpose ones
- Flatten deep nesting with guard clauses
- Replace magic numbers with named
constvalues - Replace boolean flag parameters with named parameters or enums
- Balance DRY against over-abstraction, and write comments that explain why
- Handle errors in one consistent way
1. What Is Clean Code?
Clean code is code another person — or you, in six months — can read and change quickly. We reach it by refactoring: changing how code is written without changing what it does.
Analogy — refactoring is tidying a kitchen: the same meals come out, but the next cook finds every knife at once.
The golden rule: same input, same output — compare after every small change.
2. The Messy Checkout
A working shop checkout. What does f do?
String calc(List<double> p, List<int> q, bool f, String c) {
if (p.length == q.length) {
if (p.isNotEmpty) {
var t = 0.0;
for (var i = 0; i < p.length; i++) {
t = t + p[i] * q[i];
}
if (f) {
t = t * 0.9; // multiply by 0.9
}
if (t < 2000) {
t = t + 150; // add 150
}
return '$c: ${t.toStringAsFixed(2)}';
} else {
return 'Cart is empty';
}
} else {
throw 'bad';
}
}
void main() {
print(calc([500, 250], [2, 1], true, 'Sara'));
print(calc([1200, 900], [1, 1], false, 'Ali'));
print(calc([], [], false, 'Zayan'));
}Sara: 1275.00
Ali: 2100.00
Cart is emptyThat output is our reference: every refactor below must reproduce it.
3. Small, Single-Purpose Functions
calc validates, adds up, discounts, charges delivery and formats — five jobs. Lecture 038
says: if describing it needs "and", split it. Start with the loop:
double subtotal(List<double> prices, List<int> quantities) {
var total = 0.0;
for (var i = 0; i < prices.length; i++) {
total += prices[i] * quantities[i];
}
return total;
}
void main() {
print(subtotal([500, 250], [2, 1]));
print(subtotal([1200, 900], [1, 1]));
}1250.0
2100.0Refactor one thing at a time, so when the output changes you know which step broke it.
4. Meaningful Names
The extraction above already renamed things. The full rename: calc → checkout (the job),
p, q → prices, quantities (plural for lists), c → customer, t → total. The
flag f gets no name at all — section 7 removes it.
A name's length should match its reach: i is fine for a three-line loop; a parameter that
callers see deserves a full word.
5. Guard Clauses Instead of Deep Nesting
The real work in calc sits three levels deep. Guard clauses (Lecture 038) move the bad
cases to the top:
String checkNested(List<double> prices, List<int> quantities) {
if (prices.length == quantities.length) {
if (prices.isNotEmpty) {
return '${prices.length} items';
} else {
return 'Cart is empty';
}
} else {
throw 'bad';
}
}
String check(List<double> prices, List<int> quantities) {
if (prices.length != quantities.length) throw 'bad';
if (prices.isEmpty) return 'Cart is empty';
return '${prices.length} items';
}
void main() {
print(checkNested([500, 250], [2, 1]));
print(check([500, 250], [2, 1]));
print(checkNested([], []));
print(check([], []));
}2 items
2 items
Cart is empty
Cart is emptyWe kept throw 'bad' and the returned message on purpose — this step fixes nesting only.
Errors get their own step.
6. Named Constants Instead of Magic Numbers
A magic number is a literal whose meaning lives only in the author's head: 0.9,
2000, 150. Give each one a const name (lowerCamelCase, Lecture 117):
const memberDiscountRate = 0.10;
const freeDeliveryThreshold = 2000.0;
const deliveryFee = 150.0;
double memberPriceOld(double t) => t * 0.9;
double withDeliveryOld(double t) => t < 2000 ? t + 150 : t;
double memberPrice(double total) => total * (1 - memberDiscountRate);
double withDelivery(double total) =>
total < freeDeliveryThreshold ? total + deliveryFee : total;
void main() {
print(withDeliveryOld(memberPriceOld(1250)));
print(withDelivery(memberPrice(1250)));
}1275.0
1275.0When the fee rises, you change one line. 0.10 also reads as "10% off" — the business rule —
where 0.9 hid it.
7. Replacing Boolean Flags
calc([...], [...], true, 'Sara') — what is true? A boolean flag parameter forces every
reader to open the function. Two fixes: a named parameter, or an enum:
const memberDiscountRate = 0.10;
enum CustomerType { member, guest }
double discountOld(double total, bool f) =>
f ? total * (1 - memberDiscountRate) : total;
double discountNamed(double total, {required bool isMember}) =>
isMember ? total * (1 - memberDiscountRate) : total;
double discountFor(double total, CustomerType type) => switch (type) {
CustomerType.member => total * (1 - memberDiscountRate),
CustomerType.guest => total,
};
void main() {
print(discountOld(1250, true));
print(discountNamed(1250, isMember: true));
print(discountFor(1250, CustomerType.member));
}1125.0
1125.0
1125.0isMember: true reads well at the call. With the enum, adding a staff type later makes the
switch refuse to compile until you handle it. A flag that switches what a function does
(print or return) means it should be two functions.
8. DRY — and When Not to Abstract
DRY ("Don't Repeat Yourself") says one rule should live in one place (Example 6). But it can go too far. Discount and delivery both look like "if something, change the amount", so someone writes a general helper:
const freeDeliveryThreshold = 2000.0;
const deliveryFee = 150.0;
double applyRule(
double amount,
bool Function(double) when,
double Function(double) change,
) => when(amount) ? change(amount) : amount;
double withDelivery(double total) =>
total < freeDeliveryThreshold ? total + deliveryFee : total;
void main() {
print(
applyRule(1125, (t) => t < freeDeliveryThreshold, (t) => t + deliveryFee),
);
print(withDelivery(1125));
}1275.0
1275.0Same answer, harder to read. Code that looks alike but changes for different reasons is not duplication. A useful habit: tolerate two copies; extract on the third.
9. Comments That Explain Why
// add 150 repeats the code — and lies the day someone changes 150 but not the comment.
Names and constants already say what. Comments should say why:
// Delivery is checked after the member discount: the shop promises free
// delivery on what the customer pays, not on list prices.It sits above withDelivery in section 11 and protects a business decision no name could
express. Doc comments with /// belong to Lecture 125 — Code Organization & Documentation.
10. Consistent Error Handling
The old code returns 'Cart is empty' (a string that looks like a receipt) but throws
the String 'bad'. Pick one style — throw a real error type — and catch it in one place:
String checkOld(List<double> prices, List<int> quantities) {
if (prices.length != quantities.length) throw 'bad';
if (prices.isEmpty) return 'Cart is empty';
return '${prices.length} items';
}
String check(List<double> prices, List<int> quantities) {
if (prices.length != quantities.length) {
throw ArgumentError('Each price needs a quantity');
}
if (prices.isEmpty) throw ArgumentError('Cart is empty');
return '${prices.length} items';
}
void main() {
print(checkOld([], []));
try {
print(check([], []));
} on ArgumentError catch (e) {
print(e.message);
}
}Cart is empty
Cart is emptySame output, but a caller can no longer mistake an error for a receipt.
11. A Real-World Example
Every step together — the clean checkout:
const memberDiscountRate = 0.10;
const freeDeliveryThreshold = 2000.0;
const deliveryFee = 150.0;
enum CustomerType { member, guest }
double subtotal(List<double> prices, List<int> quantities) {
var total = 0.0;
for (var i = 0; i < prices.length; i++) {
total += prices[i] * quantities[i];
}
return total;
}
double discountFor(double total, CustomerType type) => switch (type) {
CustomerType.member => total * (1 - memberDiscountRate),
CustomerType.guest => total,
};
// Delivery is checked after the member discount: the shop promises free
// delivery on what the customer pays, not on list prices.
double withDelivery(double total) =>
total < freeDeliveryThreshold ? total + deliveryFee : total;
String checkout(
String customer,
List<double> prices,
List<int> quantities,
CustomerType type,
) {
if (prices.length != quantities.length) {
throw ArgumentError('Each price needs a quantity');
}
if (prices.isEmpty) throw ArgumentError('Cart is empty');
final total = withDelivery(discountFor(subtotal(prices, quantities), type));
return '$customer: ${total.toStringAsFixed(2)}';
}
void main() {
try {
print(checkout('Sara', [500, 250], [2, 1], CustomerType.member));
print(checkout('Ali', [1200, 900], [1, 1], CustomerType.guest));
print(checkout('Zayan', [], [], CustomerType.guest));
} on ArgumentError catch (e) {
print(e.message);
}
}Sara: 1275.00
Ali: 2100.00
Cart is emptyIdentical to section 2. In a real project, lock this in first with a Lecture 119 test —
expect(checkout(...), 'Sara: 1275.00') — and rerun it after every step.
Code Examples
Each program runs old and new side by side; matching lines prove nothing changed.
Example 1 — A Meaningful Name
void main() {
var x = 25.0;
var y = x * 9 / 5 + 32;
print(y);
var celsius = 25.0;
var fahrenheit = celsius * 9 / 5 + 32;
print(fahrenheit);
}77.0
77.0Example 2 — A Named Constant
const passMark = 50;
bool passedOld(int marks) => marks >= 50;
bool hasPassed(int marks) => marks >= passMark;
void main() {
print(passedOld(72));
print(hasPassed(72));
}true
trueExample 3 — A Guard Clause
String ticketOld(int age) {
if (age >= 0) {
if (age < 12) {
return 'Child ticket';
} else {
return 'Adult ticket';
}
} else {
return 'Invalid age';
}
}
String ticket(int age) {
if (age < 0) return 'Invalid age';
if (age < 12) return 'Child ticket';
return 'Adult ticket';
}
void main() {
print(ticketOld(6));
print(ticket(6));
print(ticketOld(-1));
print(ticket(-1));
}Child ticket
Child ticket
Invalid age
Invalid ageExample 4 — A Named Flag
String priceOld(double amount, bool c) =>
c ? 'Rs ${amount.toStringAsFixed(2)}' : amount.toStringAsFixed(2);
String price(double amount, {bool withCurrency = false}) => withCurrency
? 'Rs ${amount.toStringAsFixed(2)}'
: amount.toStringAsFixed(2);
void main() {
print(priceOld(1500, true));
print(price(1500, withCurrency: true));
}Rs 1500.00
Rs 1500.00Example 5 — An Enum Instead of Two Flags
enum Delivery { standard, express, pickup }
double feeOld(bool isExpress, bool isPickup) {
if (isPickup) return 0;
if (isExpress) return 300;
return 150;
}
double fee(Delivery delivery) => switch (delivery) {
Delivery.standard => 150,
Delivery.express => 300,
Delivery.pickup => 0,
};
void main() {
print(feeOld(true, false));
print(fee(Delivery.express));
}300.0
300.0Two flags allow the meaningless feeOld(true, true); the enum cannot express it.
Example 6 — DRY: One Average
double average(List<int> marks) => marks.reduce((a, b) => a + b) / marks.length;
void main() {
final ali = [80, 75, 85];
print(ali.reduce((a, b) => a + b) / ali.length);
print(average(ali));
}80.0
80.0The formula now lives in one place.
Example 7 — Student Grade Report
const gradeABoundary = 80;
const gradeBBoundary = 65;
const passMark = 50;
String gOld(int m) {
if (m >= 80) {
return 'A';
} else {
if (m >= 65) {
return 'B';
} else {
if (m >= 50) {
return 'C';
} else {
return 'F';
}
}
}
}
String grade(int marks) {
if (marks >= gradeABoundary) return 'A';
if (marks >= gradeBBoundary) return 'B';
if (marks >= passMark) return 'C';
return 'F';
}
void main() {
for (final marks in [92, 70, 55, 30]) {
print('$marks: ${gOld(marks)} ${grade(marks)}');
}
}92: A A
70: B B
55: C C
30: F FNames, constants and early returns together: three levels of nesting became four flat lines.
Example 8 — Shopping Total
const deliveryFee = 150.0;
double totalOld(List<double> prices) {
var t = 0.0;
for (final p in prices) {
t += p;
}
return t + 150;
}
double cartTotal(List<double> prices) =>
prices.fold(0.0, (sum, price) => sum + price) + deliveryFee;
void main() {
print(totalOld([500, 250, 99.5]));
print(cartTotal([500, 250, 99.5]));
}999.5
999.5Example 9 — Bank Balance: Exceptions, Not -1
double withdrawOld(double balance, double amount) {
if (amount > balance) return -1;
return balance - amount;
}
double withdraw(double balance, double amount) {
if (amount > balance) throw StateError('Not enough money');
return balance - amount;
}
void main() {
final result = withdrawOld(1000, 5000);
print(result == -1 ? 'Not enough money' : result);
try {
print(withdraw(1000, 5000));
} on StateError catch (e) {
print(e.message);
}
}Not enough money
Not enough moneyForget to check for -1 and the balance silently becomes -1; an exception cannot be ignored.
Example 10 — Temperature: Proving a Refactor
const freezingPointF = 32;
double cOld(double x) => (x - 32) * 5 / 9;
double fahrenheitToCelsius(double fahrenheit) =>
(fahrenheit - freezingPointF) * 5 / 9;
void main() {
final readings = [32.0, 50.0, 98.6, -40.0, 212.0];
for (final f in readings) {
print('$f: ${cOld(f) == fahrenheitToCelsius(f)}');
}
}32.0: true
50.0: true
98.6: true
-40.0: true
212.0: trueCommon Beginner Mistakes
1. Changing behaviour while "cleaning"
const freeDeliveryThreshold = 2000.0;
const deliveryFee = 150.0;
double withDeliveryOld(double t) => t < 2000 ? t + 150 : t;
double withDelivery(double total) =>
total <= freeDeliveryThreshold ? total + deliveryFee : total;
void main() {
print(withDeliveryOld(2000));
print(withDelivery(2000));
}2000.0
2150.0< became <=, and only the boundary 2000 reveals it.
2. Throwing a String
void main() {
try {
throw 'bad';
} on Exception {
print('caught');
}
}Unhandled exception:
badA String is not an Exception, so typed handlers miss it. Throw a real error type (Lecture 085).
3. Comments that repeat the code (// add 150), abstractions built for "maybe one
day", and names that are too long (theTotalPriceOfAllItemsInTheCart is as unclear as
t).
Best Practices
- Record the output (or write a test) before refactoring; compare after every step.
- Change one thing at a time.
- Test boundary values such as
2000— that is where refactors slip. - Extract duplication only when the copies change for the same reason.
- Let names say what; save comments for why.
Mini Challenge
Refactor Ali's grade report without changing its output. Use a constant, a function for the average, and no repeated expressions.
void main() {
var m = [80, 75, 85];
var t = 0;
for (var x in m) {
t += x;
}
if (t / m.length >= 50) {
print('Ali: ${(t / m.length).toStringAsFixed(1)} PASS');
} else {
print('Ali: ${(t / m.length).toStringAsFixed(1)} FAIL');
}
}Ali: 80.0 PASSTry it yourself before opening the solution.
Show solution
const passMark = 50;
double average(List<int> marks) => marks.reduce((a, b) => a + b) / marks.length;
String reportLine(String name, List<int> marks) {
final avg = average(marks);
final result = avg >= passMark ? 'PASS' : 'FAIL';
return '$name: ${avg.toStringAsFixed(1)} $result';
}
void main() {
print(reportLine('Ali', [80, 75, 85]));
}Ali: 80.0 PASSPractice Questions
They run from beginner to application to challenge.
Beginner
- Suggest clean names for: a function
calcthat totals a cart, a listqof quantities, and abool fthat means "the customer is a member". - Which literals are magic numbers?
if (age >= 18),for (var i = 0; ...),price * 0.17. - What is wrong with the comment
// add 1 to countabovecount++;?
Application
-
Replace the magic number in
double tax(double price) => price * 0.17;.tax(1000)must still print170.0. -
Rewrite with guard clauses, keeping the output for
login('Sara', 'secret123'),login('Sara', 'abc')andlogin('', 'x'):String login(String user, String password) { if (user.isNotEmpty) { if (password.length >= 8) { return 'Welcome $user'; } else { return 'Password too short'; } } else { return 'Enter a name'; } } -
Replace the flag with a named parameter that defaults to
false:String receipt(String name, bool u) => u ? 'URGENT: receipt for $name' : 'Receipt for $name'; -
bool canEdit(bool isAdmin, bool isGuest) => isAdmin && !isGuest;— replace both flags with an enumRole { admin, member, guest }.
Challenge
-
This refactor changed behaviour. Find an input that proves it.
double priceOld(double t) => t >= 1000 ? t * 0.9 : t; double price(double total) => total > discountThreshold ? total * 0.9 : total; -
deposit(double balance, double amount)returns-1whenamount <= 0. Make it throwArgumentError('Amount must be positive')instead, and print the message fordeposit(1000, -5). -
Using
==, prove thatticketOldandticketfrom Example 3 agree for ages-1,0,11,12and40.
Practice Answers
Question 1 — Solution
cartTotal, quantities, and isMember — or better, an enum CustomerType.
Question 2 — Solution
18 and 0.17 (say votingAge, taxRate). A loop's starting 0 is not magic.
Question 3 — Solution
It repeats the code and can go stale. Say why instead: // The owner's visit counts too.
Question 4 — Solution
const taxRate = 0.17;
double tax(double price) => price * taxRate;
void main() {
print(tax(1000));
}170.0Question 5 — Solution
const minPasswordLength = 8;
String login(String user, String password) {
if (user.isEmpty) return 'Enter a name';
if (password.length < minPasswordLength) return 'Password too short';
return 'Welcome $user';
}
void main() {
print(login('Sara', 'secret123'));
print(login('Sara', 'abc'));
print(login('', 'x'));
}Welcome Sara
Password too short
Enter a nameThe nested version prints the same three lines.
Question 6 — Solution
String receipt(String name, {bool urgent = false}) =>
urgent ? 'URGENT: receipt for $name' : 'Receipt for $name';
void main() {
print(receipt('Ali', urgent: true));
print(receipt('Sara'));
}URGENT: receipt for Ali
Receipt for SaraQuestion 7 — Solution
enum Role { admin, member, guest }
bool canEdit(Role role) => role == Role.admin;
void main() {
print(canEdit(Role.admin));
print(canEdit(Role.guest));
}true
falseQuestion 8 — Solution
The boundary 1000 (with discountThreshold = 1000.0): >= became >, so priceOld(1000)
prints 900.0 but price(1000) prints 1000.0.
Question 9 — Solution
double deposit(double balance, double amount) {
if (amount <= 0) throw ArgumentError('Amount must be positive');
return balance + amount;
}
void main() {
try {
print(deposit(1000, -5));
} on ArgumentError catch (e) {
print(e.message);
}
}Amount must be positiveQuestion 10 — Solution
With ticketOld and ticket from Example 3 above it:
void main() {
for (final age in [-1, 0, 11, 12, 40]) {
print('$age: ${ticketOld(age) == ticket(age)}');
}
}-1: true
0: true
11: true
12: true
40: true0, 11 and 12 are the edges where refactors slip.
Quick Revision
- What is refactoring, and what must stay the same?
- What is a magic number?
- Name two replacements for a positional
boolparameter. - When is duplicated-looking code not worth extracting?
- Why is returning
-1or an error string worse than throwing?
Key Takeaways
- Refactoring changes how code is written, never what it does — compare outputs every step.
- Small, well-named functions and guard clauses keep code flat and readable.
- Give every meaningful literal a
constname. - Replace
boolflags with named parameters or enums. - DRY shared rules, but don't merge code that only looks alike.
- Comments explain why; names explain what.
- Report errors one way: throw a real error type, catch it in one place.
What's Next?
Lecture 124 — Immutability. Our clean checkout still has a weak spot: any code holding the
prices list can change it. Next we make data that cannot change behind your back.