Dart Object-Oriented Programming

Encapsulation & Access Control

40 min Lesson 8 of 8

What Is Encapsulation?

Encapsulation is the practice of bundling data (properties) and the methods that operate on that data into a single unit (a class), while controlling access to the internal details. The idea is simple: hide what should be hidden, expose what needs to be exposed. An object should be like a vending machine -- you interact through buttons (public interface), but the internal mechanics (private implementation) are hidden and protected.

Encapsulation prevents external code from putting your object into an invalid state. Without it, anyone can set bankAccount.balance = -1000000 directly, bypassing all your validation logic.

Dart’s Privacy Model: Library-Private

Dart’s access control is different from Java or C++. There are only two levels:

  • Public -- No prefix. Accessible from anywhere.
  • Library-private -- Underscore prefix (_). Accessible only within the same file (library).

There is no protected, package-private, or class-level private in Dart.

Public vs Private

class User {
  // Public -- accessible from anywhere
  String name;

  // Private -- only accessible in THIS file
  String _password;
  int _loginAttempts = 0;

  User(this.name, this._password);

  // Public method -- the controlled interface
  bool login(String password) {
    if (_loginAttempts >= 3) {
      print('Account locked. Too many attempts.');
      return false;
    }

    if (password == _password) {
      _loginAttempts = 0;
      print('Welcome, $name!');
      return true;
    }

    _loginAttempts++;
    print('Wrong password. ${3 - _loginAttempts} attempts left.');
    return false;
  }

  // Public method to change password with validation
  bool changePassword(String oldPassword, String newPassword) {
    if (oldPassword != _password) {
      print('Current password is incorrect.');
      return false;
    }
    if (newPassword.length < 8) {
      print('New password must be at least 8 characters.');
      return false;
    }
    _password = newPassword;
    print('Password changed successfully.');
    return true;
  }
}

void main() {
  var user = User('Ahmed', 'secret123');

  // Public access works
  print(user.name);       // Ahmed
  user.login('secret123');  // Welcome, Ahmed!

  // Private access -- works in SAME file
  print(user._password);  // secret123 (accessible here!)

  // But from ANOTHER file:
  // print(user._password);  // ERROR! _password is private
  // user._password = 'hacked';  // ERROR! Cannot access
}
Critical Understanding: The underscore makes members private to the file (library), not the class. Code in the same file CAN access _password directly. This is Dart’s design choice -- it simplifies testing and keeps things practical. In a real app, each class is typically in its own file, so library-private effectively becomes class-private.

Why Encapsulation Matters

Without Encapsulation (Dangerous)

// BAD -- no encapsulation
class BankAccount {
  String owner;
  double balance;  // Public! Anyone can modify directly
  List<String> transactions;

  BankAccount(this.owner, this.balance) : transactions = [];
}

void main() {
  var account = BankAccount('Ahmed', 1000);

  // Anyone can do this -- no validation!
  account.balance = -999999;  // Negative balance? Sure!
  account.balance = double.infinity;  // Infinite money? Why not!
  account.transactions.add('FAKE TRANSACTION');  // Forge history!
  account.owner = '';  // Empty owner? No problem!

  // The object is now in a completely invalid state
}

With Encapsulation (Safe)

// GOOD -- properly encapsulated
class BankAccount {
  final String _owner;
  double _balance;
  final List<String> _transactions = [];

  BankAccount(String owner, double initialBalance)
      : _owner = owner,
        _balance = initialBalance >= 0 ? initialBalance : 0;

  // Read-only access
  String get owner => _owner;
  double get balance => _balance;
  List<String> get transactions => List.unmodifiable(_transactions);
  int get transactionCount => _transactions.length;

  // Controlled write access
  void deposit(double amount) {
    if (amount <= 0) throw ArgumentError('Amount must be positive');
    _balance += amount;
    _addTransaction('DEPOSIT', amount);
  }

  bool withdraw(double amount) {
    if (amount <= 0) throw ArgumentError('Amount must be positive');
    if (amount > _balance) return false;
    _balance -= amount;
    _addTransaction('WITHDRAW', amount);
    return true;
  }

  bool transfer(BankAccount target, double amount) {
    if (withdraw(amount)) {
      target.deposit(amount);
      _addTransaction('TRANSFER_OUT to ${target.owner}', amount);
      return true;
    }
    return false;
  }

  // Private helper -- internal use only
  void _addTransaction(String type, double amount) {
    String timestamp = DateTime.now().toIso8601String().substring(0, 19);
    _transactions.add('[$timestamp] $type: \$${amount.toStringAsFixed(2)}');
  }

  @override
  String toString() => 'Account($_owner, \$${_balance.toStringAsFixed(2)})';
}

void main() {
  var account = BankAccount('Ahmed', 1000);

  // Only valid operations allowed
  account.deposit(500);          // OK
  account.withdraw(200);         // OK
  print(account.balance);        // 1300.0

  // Invalid operations are impossible
  // account._balance = -999999;    // ERROR from other files!
  // account.balance = 999;         // ERROR! No setter
  // account.transactions.add('FAKE');  // No effect! Unmodifiable
}

Encapsulation Patterns

Pattern 1: Read-Only Properties

Multiple Ways to Make Read-Only

class Config {
  // Way 1: final (set once in constructor)
  final String appName;

  // Way 2: private field + getter only
  String _apiKey;
  String get apiKey => _apiKey;

  // Way 3: computed getter (no backing field)
  String get displayName => '$appName v$_version';

  // Way 4: const (compile-time constant)
  static const int maxRetries = 3;

  double _version;
  double get version => _version;

  Config({
    required this.appName,
    required String apiKey,
    required double version,
  })  : _apiKey = apiKey,
        _version = version;
}

Pattern 2: Validated Setters

Setters That Enforce Rules

class Product {
  String _name;
  double _price;
  int _stock;

  Product({required String name, required double price, int stock = 0})
      : _name = name,
        _price = price,
        _stock = stock;

  String get name => _name;
  set name(String value) {
    if (value.trim().isEmpty) throw ArgumentError('Name cannot be empty');
    if (value.length > 100) throw ArgumentError('Name too long (max 100)');
    _name = value.trim();
  }

  double get price => _price;
  set price(double value) {
    if (value < 0) throw ArgumentError('Price cannot be negative');
    if (value > 1000000) throw ArgumentError('Price exceeds maximum');
    _price = value;
  }

  int get stock => _stock;
  set stock(int value) {
    if (value < 0) throw ArgumentError('Stock cannot be negative');
    _stock = value;
  }

  bool get inStock => _stock > 0;
}

void main() {
  var p = Product(name: 'Laptop', price: 999.99, stock: 10);

  p.name = 'MacBook Pro';    // OK
  p.price = 1299.99;           // OK

  try {
    p.price = -50;  // Throws ArgumentError
  } catch (e) {
    print(e);  // Invalid argument: Price cannot be negative
  }
}

Pattern 3: Immutable Objects

Fully Immutable Classes

class Coordinate {
  final double latitude;
  final double longitude;

  const Coordinate(this.latitude, this.longitude);

  // No setters -- completely immutable

  // Return NEW objects instead of modifying
  Coordinate move(double dLat, double dLng) {
    return Coordinate(latitude + dLat, longitude + dLng);
  }

  double distanceTo(Coordinate other) {
    double dLat = (other.latitude - latitude);
    double dLng = (other.longitude - longitude);
    return (dLat * dLat + dLng * dLng);  // Simplified
  }

  @override
  String toString() => '($latitude, $longitude)';

  @override
  bool operator ==(Object other) =>
      other is Coordinate && other.latitude == latitude && other.longitude == longitude;

  @override
  int get hashCode => latitude.hashCode ^ longitude.hashCode;
}

void main() {
  const home = Coordinate(24.4539, 54.3773);  // Abu Dhabi
  var office = home.move(0.01, 0.02);  // Returns NEW Coordinate

  print(home);    // (24.4539, 54.3773) -- unchanged!
  print(office);  // (24.4639, 54.3973)
}
Flutter Connection: Flutter widgets are immutable. A const Text('Hello') cannot be changed after creation. If you want different text, you create a new Text widget. This immutability pattern makes Flutter’s rendering engine efficient -- it can skip rebuilding unchanged widgets.

Pattern 4: Defensive Copies

Protecting Internal Collections

class TodoList {
  final String _name;
  final List<String> _items = [];

  TodoList(this._name);

  String get name => _name;

  // WRONG: exposes the real list
  // List<String> get items => _items;

  // RIGHT: return unmodifiable view
  List<String> get items => List.unmodifiable(_items);

  // RIGHT alternative: return a copy
  List<String> get itemsCopy => [..._items];

  int get count => _items.length;
  bool get isEmpty => _items.isEmpty;

  void add(String item) {
    if (item.trim().isEmpty) return;
    _items.add(item.trim());
  }

  bool remove(String item) => _items.remove(item);

  void clear() => _items.clear();
}

void main() {
  var list = TodoList('Shopping');
  list.add('Milk');
  list.add('Bread');

  // Safe -- cannot modify internal list
  var items = list.items;
  // items.add('Hack');  // Throws UnsupportedError!

  print(list.items);  // [Milk, Bread] -- still intact
}

Information Hiding in Practice

A well-encapsulated class exposes a minimal public interface and hides everything else:

Minimal Public Interface

class EmailService {
  // Private implementation details
  final String _smtpHost;
  final int _smtpPort;
  final String _username;
  final String _password;
  bool _isConnected = false;

  EmailService({
    required String host,
    required int port,
    required String username,
    required String password,
  })  : _smtpHost = host,
        _smtpPort = port,
        _username = username,
        _password = password;

  // PUBLIC INTERFACE -- only 3 methods exposed

  /// Send an email. Returns true if successful.
  Future<bool> send({
    required String to,
    required String subject,
    required String body,
  }) async {
    _ensureConnected();
    return _sendMail(to, subject, body);
  }

  /// Check if the service is ready.
  bool get isReady => _isConnected;

  /// Disconnect from the mail server.
  void disconnect() {
    if (_isConnected) {
      _closeConnection();
      _isConnected = false;
    }
  }

  // PRIVATE IMPLEMENTATION -- hidden from outside

  void _ensureConnected() {
    if (!_isConnected) {
      _connect();
    }
  }

  void _connect() {
    print('Connecting to $_smtpHost:$_smtpPort...');
    // ... SMTP connection logic
    _isConnected = true;
  }

  void _closeConnection() {
    print('Disconnecting from $_smtpHost...');
    // ... cleanup logic
  }

  Future<bool> _sendMail(String to, String subject, String body) async {
    print('Sending "$subject" to $to...');
    // ... actual SMTP send logic
    return true;
  }

  String _formatHeaders(String to, String subject) {
    return 'To: $to\nSubject: $subject\nFrom: $_username';
  }
}

void main() async {
  var email = EmailService(
    host: 'smtp.gmail.com',
    port: 587,
    username: 'me@gmail.com',
    password: 'app-password',
  );

  // Simple public interface -- all complexity is hidden
  await email.send(
    to: 'ahmed@example.com',
    subject: 'Hello!',
    body: 'This is a test email.',
  );

  email.disconnect();

  // Cannot access internals:
  // email._password;       // ERROR from other files!
  // email._connect();      // ERROR from other files!
  // email._smtpHost;       // ERROR from other files!
}
Rule of Thumb: If a member does not need to be accessed from outside the class, make it private. Start with everything private and only make things public when you have a reason. It is much easier to make a private member public later than to make a public member private (because code might already depend on it).

Encapsulation with Inheritance

Private members are not accessible in subclasses (from other files). Use protected-like patterns with public/private getters:

Inheritance-Friendly Encapsulation

// base_model.dart
class BaseModel {
  final String _id;
  DateTime _createdAt;
  DateTime _updatedAt;

  BaseModel()
      : _id = DateTime.now().millisecondsSinceEpoch.toString(),
        _createdAt = DateTime.now(),
        _updatedAt = DateTime.now();

  // Public getters -- subclasses can read these
  String get id => _id;
  DateTime get createdAt => _createdAt;
  DateTime get updatedAt => _updatedAt;

  // Protected-like method: public so subclasses can call it
  void markUpdated() {
    _updatedAt = DateTime.now();
  }
}

// user.dart (different file)
class User extends BaseModel {
  String name;
  String email;

  User({required this.name, required this.email});

  void updateEmail(String newEmail) {
    email = newEmail;
    markUpdated();  // Can call public method
    // _updatedAt = DateTime.now();  // ERROR! Cannot access private
  }

  @override
  String toString() =>
      'User($name, $email, created: $createdAt)';
}

void main() {
  var user = User(name: 'Ahmed', email: 'ahmed@test.com');
  print(user.id);         // Accessible (public getter)
  print(user.createdAt);  // Accessible (public getter)

  user.updateEmail('new@test.com');
  print(user.updatedAt);  // Updated through markUpdated()
}

Practical Example: Shopping Cart

Fully Encapsulated Shopping Cart

class CartItem {
  final String productId;
  final String name;
  final double price;
  int _quantity;

  CartItem({
    required this.productId,
    required this.name,
    required this.price,
    int quantity = 1,
  }) : _quantity = quantity > 0 ? quantity : 1;

  int get quantity => _quantity;

  set quantity(int value) {
    if (value < 1) throw ArgumentError('Quantity must be at least 1');
    if (value > 99) throw ArgumentError('Maximum 99 items per product');
    _quantity = value;
  }

  double get subtotal => price * _quantity;

  @override
  String toString() => '$name x$_quantity = \$${subtotal.toStringAsFixed(2)}';
}

class ShoppingCart {
  final List<CartItem> _items = [];
  String? _couponCode;
  double _discountPercent = 0;

  // Read-only access
  List<CartItem> get items => List.unmodifiable(_items);
  int get itemCount => _items.fold(0, (sum, item) => sum + item.quantity);
  bool get isEmpty => _items.isEmpty;
  String? get couponCode => _couponCode;

  // Computed properties
  double get subtotal => _items.fold(0, (sum, item) => sum + item.subtotal);
  double get discount => subtotal * (_discountPercent / 100);
  double get total => subtotal - discount;

  // Controlled mutations
  void addItem(String productId, String name, double price, {int quantity = 1}) {
    var existing = _findItem(productId);
    if (existing != null) {
      existing.quantity = existing.quantity + quantity;
    } else {
      _items.add(CartItem(productId: productId, name: name, price: price, quantity: quantity));
    }
  }

  void removeItem(String productId) {
    _items.removeWhere((item) => item.productId == productId);
  }

  void updateQuantity(String productId, int quantity) {
    var item = _findItem(productId);
    if (item == null) throw StateError('Item not in cart');
    if (quantity <= 0) {
      removeItem(productId);
    } else {
      item.quantity = quantity;
    }
  }

  bool applyCoupon(String code) {
    // Simulate coupon validation
    var coupons = {'SAVE10': 10.0, 'SAVE20': 20.0, 'HALF': 50.0};
    if (coupons.containsKey(code.toUpperCase())) {
      _couponCode = code.toUpperCase();
      _discountPercent = coupons[code.toUpperCase()]!;
      return true;
    }
    return false;
  }

  void removeCoupon() {
    _couponCode = null;
    _discountPercent = 0;
  }

  void clear() {
    _items.clear();
    removeCoupon();
  }

  // Private helper
  CartItem? _findItem(String productId) {
    try {
      return _items.firstWhere((item) => item.productId == productId);
    } catch (_) {
      return null;
    }
  }

  void printReceipt() {
    print('╔══════════════════════════════╗');
    print('║       SHOPPING CART          ║');
    print('╠══════════════════════════════╣');
    for (var item in _items) {
      print('║ $item');
    }
    print('╠══════════════════════════════╣');
    print('║ Subtotal: \$${subtotal.toStringAsFixed(2)}');
    if (_discountPercent > 0) {
      print('║ Coupon ($_couponCode): -\$${discount.toStringAsFixed(2)}');
    }
    print('║ TOTAL: \$${total.toStringAsFixed(2)}');
    print('╚══════════════════════════════╝');
  }
}

void main() {
  var cart = ShoppingCart();

  cart.addItem('p001', 'Laptop', 999.99);
  cart.addItem('p002', 'Mouse', 29.99, quantity: 2);
  cart.addItem('p003', 'Keyboard', 79.99);
  cart.addItem('p001', 'Laptop', 999.99);  // Adds to existing

  cart.applyCoupon('SAVE10');

  cart.printReceipt();
  // Subtotal: $2139.95
  // Coupon (SAVE10): -$214.00
  // TOTAL: $1925.96

  print('Items in cart: ${cart.itemCount}');  // 5

  // Cannot cheat:
  // cart._items.clear();         // ERROR from other files!
  // cart._discountPercent = 99;  // ERROR from other files!
}

Practice Exercise

Open DartPad and build a PasswordManager class: (1) Private field _passwords (Map<String, String>) storing site-name to encrypted-password pairs. (2) Private method _encrypt(String password) that returns a simple “encrypted” version (reverse the string + add a prefix). (3) Private method _decrypt(String encrypted) that reverses the encryption. (4) Public method store(String site, String password) that validates (min 8 chars, must have a number) and stores encrypted. (5) Public method retrieve(String site) that returns the decrypted password or null. (6) Public getter sites that returns a list of site names (NOT passwords). (7) Public getter count. (8) Public method delete(String site). (9) Make sure no method ever returns or exposes the raw internal map. Test with 3-4 sites.

Tutorial Complete!

Congratulations! You have completed all lessons in this tutorial.