Watch the video (10 min)
On this page23 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. What Is Clean Code?
  5. 2. The Messy Checkout
  6. 3. Small, Single-Purpose Functions
  7. 4. Meaningful Names
  8. 5. Guard Clauses Instead of Deep Nesting
  9. 6. Named Constants Instead of Magic Numbers
  10. 7. Replacing Boolean Flags
  11. 8. DRY — and When Not to Abstract
  12. 9. Comments That Explain Why
  13. 10. Consistent Error Handling
  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 123 — Writing Clean Dart

Category21 — Professional Dart Development
Lecture123 of 140
Estimated video length10 min
TargetDart 3.12.2
LevelAdvanced

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)
  • throw and on ... 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 const values
  • 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?

dart
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'));
}
output
Sara: 1275.00
Ali: 2100.00
Cart is empty

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

dart
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]));
}
output
1250.0
2100.0

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

dart
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([], []));
}
output
2 items
2 items
Cart is empty
Cart is empty

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

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

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

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

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

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

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

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

dart
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);
  }
}
output
Cart is empty
Cart is empty

Same output, but a caller can no longer mistake an error for a receipt.


11. A Real-World Example

Every step together — the clean checkout:

dart
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);
  }
}
output
Sara: 1275.00
Ali: 2100.00
Cart is empty

Identical 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

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

Example 2 — A Named Constant

dart
const passMark = 50;

bool passedOld(int marks) => marks >= 50;
bool hasPassed(int marks) => marks >= passMark;

void main() {
  print(passedOld(72));
  print(hasPassed(72));
}
output
true
true

Example 3 — A Guard Clause

dart
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));
}
output
Child ticket
Child ticket
Invalid age
Invalid age

Example 4 — A Named Flag

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

Example 5 — An Enum Instead of Two Flags

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

Two flags allow the meaningless feeOld(true, true); the enum cannot express it.

Example 6 — DRY: One Average

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

The formula now lives in one place.

Example 7 — Student Grade Report

dart
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)}');
  }
}
output
92: A A
70: B B
55: C C
30: F F

Names, constants and early returns together: three levels of nesting became four flat lines.

Example 8 — Shopping Total

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

Example 9 — Bank Balance: Exceptions, Not -1

dart
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);
  }
}
output
Not enough money
Not enough money

Forget to check for -1 and the balance silently becomes -1; an exception cannot be ignored.

Example 10 — Temperature: Proving a Refactor

dart
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)}');
  }
}
output
32.0: true
50.0: true
98.6: true
-40.0: true
212.0: true

Common Beginner Mistakes

1. Changing behaviour while "cleaning"

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

< became <=, and only the boundary 2000 reveals it.

2. Throwing a String

dart
void main() {
  try {
    throw 'bad';
  } on Exception {
    print('caught');
  }
}
output
Unhandled exception:
bad

A 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.

dart
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');
  }
}
output
Ali: 80.0 PASS

Try it yourself before opening the solution.

Show solution
dart
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]));
}
output
Ali: 80.0 PASS

Practice Questions

They run from beginner to application to challenge.

Beginner

  1. Suggest clean names for: a function calc that totals a cart, a list q of quantities, and a bool f that means "the customer is a member".
  2. Which literals are magic numbers? if (age >= 18), for (var i = 0; ...), price * 0.17.
  3. What is wrong with the comment // add 1 to count above count++;?

Application

  1. Replace the magic number in double tax(double price) => price * 0.17;. tax(1000) must still print 170.0.

  2. Rewrite with guard clauses, keeping the output for login('Sara', 'secret123'), login('Sara', 'abc') and login('', 'x'):

    dart
    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';
      }
    }
  3. 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';

  4. bool canEdit(bool isAdmin, bool isGuest) => isAdmin && !isGuest; — replace both flags with an enum Role { admin, member, guest }.

Challenge

  1. This refactor changed behaviour. Find an input that proves it.

    dart
    double priceOld(double t) => t >= 1000 ? t * 0.9 : t;
    double price(double total) => total > discountThreshold ? total * 0.9 : total;
  2. deposit(double balance, double amount) returns -1 when amount <= 0. Make it throw ArgumentError('Amount must be positive') instead, and print the message for deposit(1000, -5).

  3. Using ==, prove that ticketOld and ticket from Example 3 agree for ages -1, 0, 11, 12 and 40.


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

dart
const taxRate = 0.17;

double tax(double price) => price * taxRate;

void main() {
  print(tax(1000));
}
output
170.0

Question 5 — Solution

dart
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'));
}
output
Welcome Sara
Password too short
Enter a name

The nested version prints the same three lines.

Question 6 — Solution

dart
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'));
}
output
URGENT: receipt for Ali
Receipt for Sara

Question 7 — Solution

dart
enum Role { admin, member, guest }

bool canEdit(Role role) => role == Role.admin;

void main() {
  print(canEdit(Role.admin));
  print(canEdit(Role.guest));
}
output
true
false

Question 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

dart
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);
  }
}
output
Amount must be positive

Question 10 — Solution

With ticketOld and ticket from Example 3 above it:

dart
void main() {
  for (final age in [-1, 0, 11, 12, 40]) {
    print('$age: ${ticketOld(age) == ticket(age)}');
  }
}
output
-1: true
0: true
11: true
12: true
40: true

0, 11 and 12 are the edges where refactors slip.


Quick Revision

  1. What is refactoring, and what must stay the same?
  2. What is a magic number?
  3. Name two replacements for a positional bool parameter.
  4. When is duplicated-looking code not worth extracting?
  5. Why is returning -1 or 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 const name.
  • Replace bool flags 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.


Video lecture

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