Dart Object-Oriented Programming

Composition vs Inheritance

50 min Lesson 20 of 24

The Golden Rule: Favor Composition Over Inheritance

One of the most important principles in object-oriented programming is: “Favor composition over inheritance.” This does not mean inheritance is bad -- it means you should think carefully before using it. Inheritance creates tight coupling between parent and child. Composition gives you more flexibility by assembling objects from smaller, reusable pieces.

The key question is: Is-A vs Has-A. A Dog IS-A Animal (inheritance). A Car HAS-A Engine (composition). Getting this distinction right is the foundation of good OOP design.

The Problem with Deep Inheritance

// PROBLEMATIC: Deep inheritance hierarchy
class Animal {
  void eat() => print('Eating...');
}

class FlyingAnimal extends Animal {
  void fly() => print('Flying...');
}

class SwimmingAnimal extends Animal {
  void swim() => print('Swimming...');
}

// Problem: A duck can BOTH fly and swim!
// Dart only allows single inheritance, so you must choose one:
class Duck extends FlyingAnimal {
  // Cannot also extend SwimmingAnimal!
  // Must duplicate swimming logic here
  void swim() => print('Swimming...');
}

// What about a penguin? It swims but does NOT fly.
class Penguin extends SwimmingAnimal {
  // If we inherited from FlyingAnimal, we would have a fly()
  // method that makes no sense for a penguin.
}

// What about a flying fish? It flies AND swims but is NOT a bird!
// The hierarchy breaks down completely.
Warning: When you find yourself creating parallel hierarchies, duplicating code across branches, or forcing classes into categories that do not quite fit, your inheritance tree has become a liability. This is the fragile base class problem -- changes to a parent class can break all descendants in unexpected ways.

Composition to the Rescue

With composition, you give objects capabilities by attaching behavior objects, rather than inheriting from a parent. Each capability is a small, focused class. An animal can have any combination of capabilities.

Composition with Capability Objects

// Small, focused capability classes
class FlyingAbility {
  final double maxAltitude;
  FlyingAbility({this.maxAltitude = 1000});

  void fly() => print('Flying up to ${maxAltitude}m');
}

class SwimmingAbility {
  final double maxDepth;
  SwimmingAbility({this.maxDepth = 10});

  void swim() => print('Swimming down to ${maxDepth}m');
}

class RunningAbility {
  final double maxSpeed;
  RunningAbility({this.maxSpeed = 30});

  void run() => print('Running at ${maxSpeed}km/h');
}

// Animals HAVE capabilities (composition)
class Animal {
  final String name;
  final FlyingAbility? flying;
  final SwimmingAbility? swimming;
  final RunningAbility? running;

  Animal(this.name, {this.flying, this.swimming, this.running});

  void describe() {
    print('$name can:');
    if (flying != null) flying!.fly();
    if (swimming != null) swimming!.swim();
    if (running != null) running!.run();
  }
}

void main() {
  // A duck: flies AND swims
  final duck = Animal('Duck',
    flying: FlyingAbility(maxAltitude: 500),
    swimming: SwimmingAbility(maxDepth: 2),
  );

  // A penguin: swims AND runs, but does NOT fly
  final penguin = Animal('Penguin',
    swimming: SwimmingAbility(maxDepth: 50),
    running: RunningAbility(maxSpeed: 12),
  );

  // A flying fish: flies AND swims
  final flyingFish = Animal('Flying Fish',
    flying: FlyingAbility(maxAltitude: 5),
    swimming: SwimmingAbility(maxDepth: 100),
  );

  duck.describe();
  // Duck can:
  // Flying up to 500m
  // Swimming down to 2m

  penguin.describe();
  // Penguin can:
  // Swimming down to 50m
  // Running at 12km/h

  flyingFish.describe();
  // Flying Fish can:
  // Flying up to 5m
  // Swimming down to 100m
}
Tip: Notice how we solved the duck problem effortlessly. No class hierarchy needed. Each animal is assembled from the capabilities it has. Adding a new capability (like ClimbingAbility) requires zero changes to existing animals -- just pass it in for those that need it.

When to Use Inheritance vs Composition

Both have their place. Here are guidelines for choosing:

Decision Guide

// USE INHERITANCE when:
// 1. There is a genuine "is-a" relationship
// 2. The subclass truly IS a specialized version of the parent
// 3. You want to reuse the parent's implementation AND interface
// 4. The hierarchy is shallow (2-3 levels max)

abstract class Shape {
  double get area;
  double get perimeter;
}

class Circle extends Shape {
  final double radius;
  Circle(this.radius);

  @override
  double get area => 3.14159 * radius * radius;
  @override
  double get perimeter => 2 * 3.14159 * radius;
}
// Circle IS-A Shape -- this is correct

// USE COMPOSITION when:
// 1. There is a "has-a" or "uses-a" relationship
// 2. You need to combine multiple behaviors
// 3. Behaviors might change at runtime
// 4. You want to share behavior across unrelated classes

class GameCharacter {
  final String name;
  int health;
  MovementStrategy movement;    // HAS a movement strategy
  AttackStrategy attack;        // HAS an attack strategy
  final Inventory inventory;    // HAS an inventory

  GameCharacter({
    required this.name,
    this.health = 100,
    required this.movement,
    required this.attack,
    required this.inventory,
  });
}
// A character HAS movement, HAS attacks, HAS inventory
// A character IS NOT a movement or an attack

The Delegation Pattern

Delegation is composition where one object forwards method calls to another object it contains. The outer class presents the same interface but delegates the actual work. This lets you swap implementations without changing the outer class.

Delegation in Practice

// Interface for storage
abstract class Storage {
  Future<void> save(String key, String value);
  Future<String?> load(String key);
  Future<void> delete(String key);
}

// Concrete implementations
class MemoryStorage implements Storage {
  final Map<String, String> _data = {};

  @override
  Future<void> save(String key, String value) async {
    _data[key] = value;
    print('[Memory] Saved "$key"');
  }

  @override
  Future<String?> load(String key) async {
    print('[Memory] Loading "$key"');
    return _data[key];
  }

  @override
  Future<void> delete(String key) async {
    _data.remove(key);
    print('[Memory] Deleted "$key"');
  }
}

class FileStorage implements Storage {
  @override
  Future<void> save(String key, String value) async {
    print('[File] Writing "$key" to disk');
  }

  @override
  Future<String?> load(String key) async {
    print('[File] Reading "$key" from disk');
    return 'file_value';
  }

  @override
  Future<void> delete(String key) async {
    print('[File] Removing "$key" from disk');
  }
}

// CacheService DELEGATES to a Storage implementation
class CacheService {
  Storage _storage;
  final Duration ttl;

  CacheService({required Storage storage, this.ttl = const Duration(minutes: 5)})
      : _storage = storage;

  // Can swap storage at runtime!
  void switchStorage(Storage newStorage) {
    _storage = newStorage;
    print('Switched storage backend');
  }

  Future<void> cache(String key, String value) async {
    await _storage.save(key, value);
    await _storage.save('${key}_expiry', DateTime.now().add(ttl).toIso8601String());
  }

  Future<String?> get(String key) async {
    return await _storage.load(key);
  }

  Future<void> invalidate(String key) async {
    await _storage.delete(key);
    await _storage.delete('${key}_expiry');
  }
}

void main() async {
  // Start with memory storage (fast, for development)
  final cache = CacheService(storage: MemoryStorage());
  await cache.cache('user_name', 'Alice');
  await cache.get('user_name');

  // Switch to file storage (persistent, for production)
  cache.switchStorage(FileStorage());
  await cache.cache('user_name', 'Alice');
  await cache.get('user_name');
}
// [Memory] Saved "user_name"
// [Memory] Saved "user_name_expiry"
// [Memory] Loading "user_name"
// Switched storage backend
// [File] Writing "user_name" to disk
// [File] Writing "user_name_expiry" to disk
// [File] Reading "user_name" from disk
Key Insight: CacheService does not extend Storage -- it contains a Storage object and delegates calls to it. This means you can swap from MemoryStorage to FileStorage (or even a RedisStorage) without changing a single line in CacheService. This is the power of delegation.

Dependency Injection Basics

Dependency Injection (DI) is the practice of passing dependencies to an object instead of letting the object create them internally. It is composition applied to object construction. DI makes code more testable, flexible, and decoupled.

Dependency Injection

// WITHOUT dependency injection -- tightly coupled
class BadUserService {
  // Creates its own dependency -- impossible to test or swap
  final _api = HttpApiClient();

  Future<String> getUsername(int id) async {
    final response = await _api.get('/users/$id');
    return response['name'];
  }
}

// WITH dependency injection -- loosely coupled
abstract class ApiClient {
  Future<Map<String, dynamic>> get(String path);
}

class HttpApiClient implements ApiClient {
  @override
  Future<Map<String, dynamic>> get(String path) async {
    print('HTTP GET: $path');
    return {'name': 'Alice'}; // simulated
  }
}

class MockApiClient implements ApiClient {
  @override
  Future<Map<String, dynamic>> get(String path) async {
    return {'name': 'Test User'};
  }
}

class UserService {
  final ApiClient _client; // Dependency is INJECTED

  UserService(this._client); // via constructor

  Future<String> getUsername(int id) async {
    final response = await _client.get('/users/$id');
    return response['name'] ?? 'Unknown';
  }
}

void main() async {
  // In production:
  final prodService = UserService(HttpApiClient());
  print(await prodService.getUsername(1));
  // HTTP GET: /users/1
  // Alice

  // In tests:
  final testService = UserService(MockApiClient());
  print(await testService.getUsername(1));
  // Test User -- no HTTP call needed!
}
Flutter Connection: Dependency injection is foundational to Flutter architecture. Packages like get_it (service locator), provider, and riverpod all facilitate DI. When you pass a Repository to a Bloc, or register a service with GetIt, you are doing dependency injection -- giving objects their dependencies from the outside rather than creating them internally.

Practical Example: Game Entity System

Let us build a game entity system that demonstrates composition, delegation, and dependency injection working together:

Game Entity System with Composition

// Composable behaviors (components)
abstract class MovementBehavior {
  String get type;
  void move(String direction);
}

class WalkMovement implements MovementBehavior {
  @override
  String get type => 'walk';

  @override
  void move(String direction) => print('  Walking $direction');
}

class FlyMovement implements MovementBehavior {
  @override
  String get type => 'fly';

  @override
  void move(String direction) => print('  Flying $direction');
}

class SwimMovement implements MovementBehavior {
  @override
  String get type => 'swim';

  @override
  void move(String direction) => print('  Swimming $direction');
}

abstract class AttackBehavior {
  String get type;
  int get damage;
  void attack(String target);
}

class MeleeAttack implements AttackBehavior {
  @override
  String get type => 'melee';
  @override
  int get damage => 25;

  @override
  void attack(String target) => print('  Sword strike on $target! ($damage dmg)');
}

class RangedAttack implements AttackBehavior {
  @override
  String get type => 'ranged';
  @override
  int get damage => 15;

  @override
  void attack(String target) => print('  Arrow shot at $target! ($damage dmg)');
}

class MagicAttack implements AttackBehavior {
  @override
  String get type => 'magic';
  @override
  int get damage => 40;

  @override
  void attack(String target) => print('  Fireball at $target! ($damage dmg)');
}

// Entity composed from behaviors
class GameEntity {
  final String name;
  int health;
  MovementBehavior movement;
  AttackBehavior attack;

  GameEntity({
    required this.name,
    this.health = 100,
    required this.movement,
    required this.attack,
  });

  void performMove(String direction) {
    print('$name (HP: $health):');
    movement.move(direction);
  }

  void performAttack(String target) {
    print('$name attacks:');
    attack.attack(target);
  }

  // Behaviors can be swapped at runtime!
  void equipWeapon(AttackBehavior newAttack) {
    print('$name switches from ${attack.type} to ${newAttack.type}');
    attack = newAttack;
  }

  void gainAbility(MovementBehavior newMovement) {
    print('$name gains ${newMovement.type} ability!');
    movement = newMovement;
  }
}

void main() {
  // Warrior: walks + melee
  final warrior = GameEntity(
    name: 'Warrior',
    health: 120,
    movement: WalkMovement(),
    attack: MeleeAttack(),
  );

  // Mage: walks + magic
  final mage = GameEntity(
    name: 'Mage',
    health: 80,
    movement: WalkMovement(),
    attack: MagicAttack(),
  );

  // Dragon: flies + melee (claws)
  final dragon = GameEntity(
    name: 'Dragon',
    health: 300,
    movement: FlyMovement(),
    attack: MeleeAttack(),
  );

  warrior.performMove('north');
  warrior.performAttack('Dragon');

  // Warrior finds a magic staff!
  warrior.equipWeapon(MagicAttack());
  warrior.performAttack('Dragon');

  // Mage drinks flight potion
  mage.gainAbility(FlyMovement());
  mage.performMove('up');
}
// Warrior (HP: 120):
//   Walking north
// Warrior attacks:
//   Sword strike on Dragon! (25 dmg)
// Warrior switches from melee to magic
// Warrior attacks:
//   Fireball at Dragon! (40 dmg)
// Mage gains fly ability!
// Mage (HP: 80):
//   Flying up
Why This Works: With inheritance, a Warrior class that extends MeleeCharacter could never gain magic. You would need to create MeleeWarrior, MagicWarrior, FlyingMeleeWarrior, etc. -- a combinatorial explosion. With composition, you simply swap the attack object. The entity does not care what kind of attack it has; it just delegates to whatever AttackBehavior it currently holds.
Common Mistake: Do not use composition for genuine is-a relationships just because someone said “composition over inheritance.” If Circle truly IS-A Shape and will never need to be something else, inheritance is the right tool. The rule is “favor composition,” not “always use composition.”
Summary Checklist: (1) If it IS-A relationship and the hierarchy is shallow, consider inheritance. (2) If it HAS-A relationship, use composition. (3) If you need to combine multiple behaviors, use composition. (4) If behaviors might change at runtime, use composition. (5) If you find yourself fighting the inheritance hierarchy, refactor to composition. (6) Always inject dependencies instead of creating them internally.