Encapsulation & Access Control
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
}
_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)
}
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!
}
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.