DEV Community

Putra Prima A
Putra Prima A

Posted on

Dart Object Oriented For Beginner : Expense Manager Case Study Part 4

Lesson 4: Protecting Your Data (Encapsulation)

Duration: 35 minutes

App Feature: πŸ”’ Making Sure Expenses Are Valid

What You'll Build: Add validation so users can't create invalid expenses

Prerequisites: Complete Lessons 1-3


What We'll Learn Today

By the end of this lesson, you'll be able to:

  • βœ… Make properties private to protect data
  • βœ… Use getters to read data safely
  • βœ… Use setters to validate data before saving
  • βœ… Prevent bugs like negative expenses or invalid categories
  • βœ… Understand why encapsulation matters

Part 1: The Problem - Unprotected Data

Let's look at our current Expense class:

class Expense {
  String description;
  double amount;
  String category;
  DateTime date;

  Expense(this.description, this.amount, this.category, this.date);
}

void main() {
  var coffee = Expense('Coffee', 4.50, 'Food', DateTime.now());

  // Uh oh... we can do this!
  coffee.amount = -100.50;  // Negative expense?!
  coffee.category = 'asdfgh';  // Invalid category!
  coffee.description = '';  // Empty description!

  print('Amount: \$${coffee.amount}');  // -$100.50 😱
}
Enter fullscreen mode Exit fullscreen mode

Problems:

  1. ❌ Anyone can set amount to a negative number
  2. ❌ Invalid categories like "asdfgh" are allowed
  3. ❌ Empty descriptions are possible
  4. ❌ No way to validate data
  5. ❌ Easy to create bugs

What we need: A way to protect our data and validate it before accepting changes!


Part 2: Making Properties Private

In Dart, you make something private by adding an underscore _ prefix:

class Expense {
  String _description;  // Private - can't access from outside
  double _amount;       // Private
  String _category;     // Private
  DateTime _date;       // Private

  Expense(this._description, this._amount, this._category, this._date);
}

void main() {
  var coffee = Expense('Coffee', 4.50, 'Food', DateTime.now());

  // ❌ ERROR! These don't work anymore
  // coffee._amount = -100;
  // print(coffee._description);

  // Private properties can't be accessed from outside the class
}
Enter fullscreen mode Exit fullscreen mode

Understanding Private Properties:

String _description;  // Underscore = private
Enter fullscreen mode Exit fullscreen mode
  • Can only be accessed inside the class
  • Protected from outside modification
  • No one can directly change the value

But wait... If everything is private, how do we read or change values? πŸ€”


Part 3: Getters - Reading Data Safely

Getters let you read private data in a controlled way:

class Expense {
  String _description;
  double _amount;
  String _category;
  DateTime _date;

  Expense(this._description, this._amount, this._category, this._date);

  // Getters - read-only access
  String get description => _description;
  double get amount => _amount;
  String get category => _category;
  DateTime get date => _date;
}

void main() {
  var coffee = Expense('Coffee', 4.50, 'Food', DateTime.now());

  // βœ… Can read using getters
  print('Description: ${coffee.description}');
  print('Amount: \$${coffee.amount}');

  // ❌ But can't modify directly
  // coffee.amount = -100;  // ERROR!
}
Enter fullscreen mode Exit fullscreen mode

Understanding Getter Syntax:

double get amount => _amount;
Enter fullscreen mode Exit fullscreen mode
  • double = return type
  • get = keyword for getter
  • amount = getter name (no underscore!)
  • => = arrow function (shorthand)
  • _amount = returns the private property

Alternative syntax (with function body):

double get amount {
  return _amount;
}
Enter fullscreen mode Exit fullscreen mode

Both work the same way!


Part 4: Setters - Writing Data with Validation

Setters let you change private data, but with validation:

class Expense {
  String _description;
  double _amount;
  String _category;
  DateTime _date;

  Expense(this._description, this._amount, this._category, this._date);

  // Getters
  String get description => _description;
  double get amount => _amount;
  String get category => _category;
  DateTime get date => _date;

  // Setters with validation
  set amount(double value) {
    if (value < 0) {
      print('❌ Error: Amount cannot be negative!');
      return;  // Don't change the value
    }
    _amount = value;
  }

  set description(String value) {
    if (value.trim().isEmpty) {
      print('❌ Error: Description cannot be empty!');
      return;
    }
    _description = value;
  }

  set category(String value) {
    List<String> validCategories = [
      'Food', 
      'Transport', 
      'Bills', 
      'Entertainment', 
      'Health',
      'Shopping',
      'Other'
    ];

    if (!validCategories.contains(value)) {
      print('❌ Error: Invalid category "$value". Using "Other".');
      _category = 'Other';
      return;
    }
    _category = value;
  }
}

void main() {
  var coffee = Expense('Coffee', 4.50, 'Food', DateTime.now());

  // βœ… Valid changes work
  coffee.amount = 5.00;
  print('New amount: \$${coffee.amount}');

  // ❌ Invalid changes are rejected
  coffee.amount = -10;  // Prints error, doesn't change
  print('Amount still: \$${coffee.amount}');  // Still 5.00

  coffee.description = '';  // Prints error, doesn't change
  coffee.category = 'InvalidCategory';  // Changes to 'Other'
}
Enter fullscreen mode Exit fullscreen mode

Output:

New amount: $5.00
❌ Error: Amount cannot be negative!
Amount still: $5.00
❌ Error: Description cannot be empty!
❌ Error: Invalid category "InvalidCategory". Using "Other".
Enter fullscreen mode Exit fullscreen mode

Understanding Setter Syntax:

set amount(double value) {
  if (value < 0) {
    print('❌ Error: Amount cannot be negative!');
    return;
  }
  _amount = value;
}
Enter fullscreen mode Exit fullscreen mode
  • set = keyword for setter
  • amount = setter name (matches getter name)
  • (double value) = the new value being set
  • Validate before setting _amount
  • return early if invalid

Part 5: Validation in Constructor

We should also validate when creating an expense:

class Expense {
  String _description;
  double _amount;
  String _category;
  DateTime _date;

  Expense(String description, double amount, String category, DateTime date)
    : _description = description,
      _amount = amount,
      _category = category,
      _date = date {
    // Validate in constructor body
    if (_amount < 0) {
      throw Exception('Amount cannot be negative!');
    }
    if (_description.trim().isEmpty) {
      throw Exception('Description cannot be empty!');
    }

    List<String> validCategories = [
      'Food', 'Transport', 'Bills', 'Entertainment', 'Health', 'Shopping', 'Other'
    ];
    if (!validCategories.contains(_category)) {
      print('⚠️ Warning: Invalid category "$_category". Changed to "Other".');
      _category = 'Other';
    }
  }

  // Getters
  String get description => _description;
  double get amount => _amount;
  String get category => _category;
  DateTime get date => _date;

  // Setters
  set amount(double value) {
    if (value < 0) {
      print('❌ Error: Amount cannot be negative!');
      return;
    }
    _amount = value;
  }

  set category(String value) {
    List<String> validCategories = [
      'Food', 'Transport', 'Bills', 'Entertainment', 'Health', 'Shopping', 'Other'
    ];
    if (!validCategories.contains(value)) {
      print('❌ Error: Invalid category. Using "Other".');
      _category = 'Other';
      return;
    }
    _category = value;
  }
}

void main() {
  try {
    var invalid = Expense('', -50, 'Food', DateTime.now());
  } catch (e) {
    print('Failed to create expense: $e');
  }

  // This works but changes category
  var expense = Expense('Coffee', 4.50, 'InvalidCat', DateTime.now());
  print('Category: ${expense.category}');  // 'Other'
}
Enter fullscreen mode Exit fullscreen mode

Part 6: Computed Properties with Getters

Getters can also calculate values on-the-fly:

class Expense {
  String _description;
  double _amount;
  String _category;
  DateTime _date;

  Expense(this._description, this._amount, this._category, this._date) {
    if (_amount < 0) throw Exception('Amount cannot be negative!');
  }

  // Regular getters
  String get description => _description;
  double get amount => _amount;
  String get category => _category;
  DateTime get date => _date;

  // Computed getters - calculate on the fly
  bool get isMajorExpense => _amount > 100;

  bool get isThisMonth {
    DateTime now = DateTime.now();
    return _date.year == now.year && _date.month == now.month;
  }

  String get formattedAmount => '\$${_amount.toStringAsFixed(2)}';

  String get formattedDate => _date.toString().split(' ')[0];

  int get daysAgo => DateTime.now().difference(_date).inDays;

  String get summary => '$_description: $formattedAmount [$_category]';

  // Emoji based on amount
  String get categoryEmoji {
    switch (_category) {
      case 'Food': return 'πŸ”';
      case 'Transport': return 'πŸš—';
      case 'Bills': return 'πŸ’‘';
      case 'Entertainment': return '🎬';
      case 'Health': return 'πŸ’Š';
      case 'Shopping': return 'πŸ›οΈ';
      default: return 'πŸ“';
    }
  }
}

void main() {
  var coffee = Expense('Coffee', 4.50, 'Food', DateTime.now());
  var laptop = Expense('Laptop', 899.99, 'Shopping', DateTime(2025, 10, 5));

  print(coffee.summary);
  print('Is major? ${coffee.isMajorExpense}');
  print('Days ago: ${coffee.daysAgo}');
  print('${coffee.categoryEmoji} ${coffee.category}');

  print('\n${laptop.summary}');
  print('Is major? ${laptop.isMajorExpense}');
  print('${laptop.categoryEmoji} ${laptop.category}');
}
Enter fullscreen mode Exit fullscreen mode

Output:

Coffee: $4.50 [Food]
Is major? false
Days ago: 0
πŸ” Food

Laptop: $899.99 [Shopping]
Is major? true
πŸ›οΈ Shopping
Enter fullscreen mode Exit fullscreen mode

Part 7: Complete Protected Expense Class

Here's a production-ready Expense class with full encapsulation:

class Expense {
  // Private properties
  String _description;
  double _amount;
  String _category;
  DateTime _date;
  String? _notes;
  bool _isPaid;

  // Valid categories list
  static const List<String> validCategories = [
    'Food',
    'Transport',
    'Bills',
    'Entertainment',
    'Health',
    'Shopping',
    'Other',
  ];

  // Constructor with validation
  Expense({
    required String description,
    required double amount,
    required String category,
    DateTime? date,
    String? notes,
    bool isPaid = false,
  }) : _description = description,
       _amount = amount,
       _category = category,
       _date = date ?? DateTime.now(),
       _notes = notes,
       _isPaid = isPaid {
    // Validate amount
    if (_amount < 0) {
      throw Exception('Amount cannot be negative');
    }

    // Validate description
    if (_description.trim().isEmpty) {
      throw Exception('Description cannot be empty');
    }

    // Validate category
    if (!validCategories.contains(_category)) {
      print('⚠️ Invalid category "$_category". Changed to "Other".');
      _category = 'Other';
    }
  }

  // Named constructor
  Expense.quick(String description, double amount, String category)
    : this(description: description, amount: amount, category: category);

  // Getters (read-only)
  String get description => _description;
  double get amount => _amount;
  String get category => _category;
  DateTime get date => _date;
  String? get notes => _notes;
  bool get isPaid => _isPaid;

  // Computed getters
  bool get isMajorExpense => _amount > 100;
  String get formattedAmount => '\$${_amount.toStringAsFixed(2)}';
  String get formattedDate => _date.toString().split(' ')[0];

  String get categoryEmoji {
    switch (_category) {
      case 'Food': return 'πŸ”';
      case 'Transport': return 'πŸš—';
      case 'Bills': return 'πŸ’‘';
      case 'Entertainment': return '🎬';
      case 'Health': return 'πŸ’Š';
      case 'Shopping': return 'πŸ›οΈ';
      default: return 'πŸ“';
    }
  }

  // Setters with validation
  set amount(double value) {
    if (value < 0) {
      throw Exception('Amount cannot be negative');
    }
    _amount = value;
  }

  set description(String value) {
    if (value.trim().isEmpty) {
      throw Exception('Description cannot be empty');
    }
    _description = value.trim();
  }

  set category(String value) {
    if (!validCategories.contains(value)) {
      throw Exception('Invalid category: $value');
    }
    _category = value;
  }

  set notes(String? value) {
    _notes = value;
  }

  set isPaid(bool value) {
    _isPaid = value;
  }

  // Methods
  void markAsPaid() {
    _isPaid = true;
    print('βœ… Marked as paid: $_description');
  }

  void markAsUnpaid() {
    _isPaid = false;
    print('❌ Marked as unpaid: $_description');
  }

  void addNote(String note) {
    if (_notes == null || _notes!.isEmpty) {
      _notes = note;
    } else {
      _notes = '$_notes; $note';
    }
  }

  void printDetails() {
    String paidStatus = _isPaid ? 'βœ… Paid' : '❌ Unpaid';
    String noteText = _notes != null ? '\n   πŸ“ $_notes' : '';

    print('─────────────────────────────');
    print('$categoryEmoji $_description');
    print('πŸ’° $formattedAmount');
    print('πŸ“ $_category');
    print('πŸ“… $formattedDate');
    print('$paidStatus$noteText');
  }
}

void main() {
  print('🏦 EXPENSE MANAGER WITH VALIDATION\n');

  // Create valid expenses
  var coffee = Expense.quick('Morning coffee', 4.50, 'Food');
  var rent = Expense(
    description: 'Monthly rent',
    amount: 1200.0,
    category: 'Bills',
    isPaid: true,
  );

  coffee.printDetails();
  print('');
  rent.printDetails();

  print('\nπŸ“ TESTING VALIDATION:\n');

  // Test setters
  print('Changing coffee amount to 5.00...');
  coffee.amount = 5.00;
  print('βœ… Success! New amount: ${coffee.formattedAmount}');

  try {
    print('\nTrying to set negative amount...');
    coffee.amount = -10;
  } catch (e) {
    print('❌ Caught error: $e');
  }

  try {
    print('\nTrying to set empty description...');
    coffee.description = '';
  } catch (e) {
    print('❌ Caught error: $e');
  }

  try {
    print('\nTrying invalid category...');
    coffee.category = 'InvalidCategory';
  } catch (e) {
    print('❌ Caught error: $e');
  }

  print('\nβœ… Adding valid note...');
  coffee.addNote('Bought at Starbucks');
  coffee.printDetails();
}
Enter fullscreen mode Exit fullscreen mode

Part 8: Why Encapsulation Matters

Real-World Benefits:

1. Data Integrity πŸ›‘οΈ

// Without encapsulation - bugs everywhere!
expense.amount = -999;  // Oops!
expense.category = 'asfgh';  // Typo!

// With encapsulation - protected!
expense.amount = -999;  // ❌ Throws error
expense.category = 'asfgh';  // ❌ Throws error
Enter fullscreen mode Exit fullscreen mode

2. Easier Debugging πŸ›

// Without encapsulation - amount changes everywhere
// Where did the bug come from? No idea!

// With encapsulation - only through setters
// Search for "set amount" to find where it changes
Enter fullscreen mode Exit fullscreen mode

3. Future Changes πŸ”„

// Need to change how amount is stored?
// With encapsulation, change internal code only
// External code using getters/setters keeps working!

class Expense {
  int _amountInCents;  // Changed storage format

  double get amount => _amountInCents / 100;  // Convert on read
  set amount(double value) => _amountInCents = (value * 100).round();
}
Enter fullscreen mode Exit fullscreen mode

4. Business Rules πŸ“‹

// Enforce business logic in one place
set amount(double value) {
  if (value < 0) throw Exception('Negative amount');
  if (value > 10000) {
    print('⚠️ Large amount! Requires approval.');
  }
  _amount = value;
}
Enter fullscreen mode Exit fullscreen mode

🎯 Practice Exercises

Exercise 1: Protected Category Class (Easy)

Create a Category class with:

  • Private properties: _name, _icon, _budget
  • Getters for all properties
  • Setter for budget that only allows positive values
  • Computed getter isOverBudget(double spent) that returns true if spent > budget

Solution:

class Category {
  String _name;
  String _icon;
  double _budget;

  Category(this._name, this._icon, this._budget) {
    if (_budget < 0) {
      throw Exception('Budget cannot be negative');
    }
  }

  // Getters
  String get name => _name;
  String get icon => _icon;
  double get budget => _budget;

  // Setter with validation
  set budget(double value) {
    if (value < 0) {
      throw Exception('Budget cannot be negative');
    }
    _budget = value;
  }

  // Method
  bool isOverBudget(double spent) {
    return spent > _budget;
  }

  void printStatus(double spent) {
    String status = isOverBudget(spent) ? '❌ Over budget!' : 'βœ… Within budget';
    print('$_icon $_name: \$${spent.toStringAsFixed(2)} / \$${_budget.toStringAsFixed(2)} - $status');
  }
}

void main() {
  var food = Category('Food', 'πŸ”', 500.0);

  food.printStatus(350.0);  // Within budget
  food.printStatus(550.0);  // Over budget

  // Test validation
  try {
    food.budget = -100;
  } catch (e) {
    print('Error: $e');
  }
}
Enter fullscreen mode Exit fullscreen mode

Exercise 2: Smart User Class (Medium)

Create a User class with:

  • Private: _name, _email, _age
  • Validate email must contain '@'
  • Validate age must be between 13 and 120
  • Computed getter isAdult (age >= 18)
  • Computed getter displayName (name + age category)

Solution:

class User {
  String _name;
  String _email;
  int _age;

  User({
    required String name,
    required String email,
    required int age,
  }) : _name = name,
       _email = email,
       _age = age {
    if (!_email.contains('@')) {
      throw Exception('Invalid email format');
    }
    if (_age < 13 || _age > 120) {
      throw Exception('Age must be between 13 and 120');
    }
  }

  // Getters
  String get name => _name;
  String get email => _email;
  int get age => _age;

  // Computed getters
  bool get isAdult => _age >= 18;

  String get ageCategory {
    if (_age < 18) return 'Minor';
    if (_age < 65) return 'Adult';
    return 'Senior';
  }

  String get displayName => '$_name ($ageCategory, $_age)';

  // Setters with validation
  set email(String value) {
    if (!value.contains('@')) {
      throw Exception('Invalid email format');
    }
    _email = value;
  }

  set age(int value) {
    if (value < 13 || value > 120) {
      throw Exception('Age must be between 13 and 120');
    }
    _age = value;
  }

  void printProfile() {
    print('πŸ‘€ $displayName');
    print('πŸ“§ $_email');
    print('Status: ${isAdult ? "Adult βœ…" : "Minor ❌"}');
  }
}

void main() {
  var user = User(name: 'John Doe', email: 'john@example.com', age: 25);
  user.printProfile();

  print('\n--- Testing validation ---');

  try {
    user.email = 'invalidemail';
  } catch (e) {
    print('❌ $e');
  }

  try {
    user.age = 150;
  } catch (e) {
    print('❌ $e');
  }
}
Enter fullscreen mode Exit fullscreen mode

Exercise 3: Bank Account Class (Hard)

Create a BankAccount class with:

  • Private: _accountHolder, _balance, _pin
  • Methods: deposit(amount), withdraw(amount, pin)
  • Cannot withdraw without correct PIN
  • Cannot withdraw more than balance
  • Cannot deposit negative amounts
  • Getter for balance (but not setter!)
  • Method changePin(oldPin, newPin)

Solution:

class BankAccount {
  String _accountHolder;
  double _balance;
  String _pin;

  BankAccount({
    required String accountHolder,
    required String pin,
    double initialBalance = 0,
  }) : _accountHolder = accountHolder,
       _pin = pin,
       _balance = initialBalance {
    if (_pin.length != 4) {
      throw Exception('PIN must be 4 digits');
    }
    if (_balance < 0) {
      throw Exception('Initial balance cannot be negative');
    }
  }

  // Getters
  String get accountHolder => _accountHolder;
  double get balance => _balance;  // Read-only, no setter!

  // Deposit method
  void deposit(double amount) {
    if (amount <= 0) {
      print('❌ Deposit amount must be positive');
      return;
    }
    _balance += amount;
    print('βœ… Deposited \$${amount.toStringAsFixed(2)}. New balance: \$${_balance.toStringAsFixed(2)}');
  }

  // Withdraw method with PIN validation
  bool withdraw(double amount, String pin) {
    if (pin != _pin) {
      print('❌ Incorrect PIN');
      return false;
    }

    if (amount <= 0) {
      print('❌ Withdrawal amount must be positive');
      return false;
    }

    if (amount > _balance) {
      print('❌ Insufficient funds. Balance: \$${_balance.toStringAsFixed(2)}');
      return false;
    }

    _balance -= amount;
    print('βœ… Withdrew \$${amount.toStringAsFixed(2)}. New balance: \$${_balance.toStringAsFixed(2)}');
    return true;
  }

  // Change PIN
  bool changePin(String oldPin, String newPin) {
    if (oldPin != _pin) {
      print('❌ Incorrect old PIN');
      return false;
    }

    if (newPin.length != 4) {
      print('❌ New PIN must be 4 digits');
      return false;
    }

    _pin = newPin;
    print('βœ… PIN changed successfully');
    return true;
  }

  void printStatement() {
    print('─────────────────────────────');
    print('Account Holder: $_accountHolder');
    print('Balance: \$${_balance.toStringAsFixed(2)}');
    print('─────────────────────────────');
  }
}

void main() {
  var account = BankAccount(
    accountHolder: 'John Doe',
    pin: '1234',
    initialBalance: 1000.0,
  );

  account.printStatement();

  print('\n--- Transactions ---');
  account.deposit(500.0);
  account.withdraw(200.0, '1234');
  account.withdraw(200.0, '0000');  // Wrong PIN
  account.withdraw(2000.0, '1234');  // Insufficient funds

  print('\n--- Change PIN ---');
  account.changePin('0000', '5678');  // Wrong old PIN
  account.changePin('1234', '5678');  // Success

  print('');
  account.printStatement();
}
Enter fullscreen mode Exit fullscreen mode

Common Mistakes & How to Fix Them

Mistake 1: Forgetting the Underscore

// ❌ Wrong - not private
String description;

// βœ… Correct - private
String _description;
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Making Getter and Setter Names Different

// ❌ Wrong - names don't match
double get amount => _amount;
set setAmount(double value) { _amount = value; }

// βœ… Correct - same name
double get amount => _amount;
set amount(double value) { _amount = value; }
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Forgetting Validation

// ❌ Wrong - no validation
set amount(double value) {
  _amount = value;
}

// βœ… Correct - validate first
set amount(double value) {
  if (value < 0) {
    throw Exception('Amount cannot be negative');
  }
  _amount = value;
}
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Direct Access to Private Properties Outside Class

// ❌ Wrong - trying to access private property
print(expense._amount);

// βœ… Correct - use getter
print(expense.amount);
Enter fullscreen mode Exit fullscreen mode

Key Concepts Review

βœ… Private properties use underscore _ prefix

βœ… Getters provide read access with get keyword

βœ… Setters provide write access with set keyword and validation

βœ… Validation prevents invalid data from entering your objects

βœ… Encapsulation protects data and enforces business rules

βœ… Computed getters calculate values on-the-fly

βœ… Data integrity is maintained through controlled access


Self-Check Questions

1. What does encapsulation mean?

Answer:
Encapsulation means hiding internal data and providing controlled access through getters and setters. It protects data from invalid changes and enforces business rules.

2. Why use getters and setters instead of public properties?

Answer:

  • Validate data before accepting changes
  • Prevent invalid states (like negative amounts)
  • Can change internal implementation without breaking external code
  • Enforce business rules in one place
  • Better debugging (know where data changes)

3. What's the difference between a regular getter and a computed getter?

Answer:

  • Regular getter: Returns a stored private property (get amount => _amount)
  • Computed getter: Calculates a value on-the-fly (get isAdult => _age >= 18)

What's Next?

In Lesson 5: Adding Useful Features, we'll learn:

  • More advanced methods for our Expense class
  • Working with lists of expenses
  • Filtering and sorting
  • Calculating totals and statistics
  • Building helper methods

Example preview:

class ExpenseManager {
  List<Expense> _expenses = [];

  void addExpense(Expense expense) { ... }
  double getTotalSpending() { ... }
  List<Expense> getByCategory(String category) { ... }
  Expense? getLargestExpense() { ... }
}
Enter fullscreen mode Exit fullscreen mode

See you in Lesson 5! πŸš€


Additional Resources

  • Practice creating other protected classes: CreditCard, Budget, UserProfile
  • Think about what data should be protected in a real app
  • Consider what validation rules make sense for your expense manager

Remember: Encapsulation isn't about making everything private - it's about protecting data that needs protection and providing safe ways to interact with it! πŸ”’

Top comments (0)