Composition vs Inheritance
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.
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
}
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
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!
}
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
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.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.”