On this page23 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. What Is Composition?
- 2. A Car That Has an Engine
- 3. Delegation
- 4. The Fragile Hierarchy
- 5. Refactoring to Composition
- 6. Injecting Behaviour Through the Constructor
- 7. Changing Behaviour While the Program Runs
- 8. Adding a New Behaviour
- 9. Favor Composition over Inheritance
- 10. When Inheritance Is Right
- 11. A Real-World Example
- Code Examples
- Common Beginner Mistakes
- Best Practices
- Mini Challenge
- Practice Questions
- Practice Answers
- Quick Revision
- Key Takeaways
- What's Next?
Lecture 66 — Composition vs Inheritance
| Category | 10 — Advanced OOP |
| Lecture | 066 of 140 |
| Estimated video length | 10 min |
| Target | Dart 3.12.2 |
| Level | Intermediate |
Inheritance says "a B is an A". Composition says "a B has an A". Building objects from smaller objects keeps your classes flexible when the requirements change.
Contents
Prerequisites · Connection · Objectives · Lesson · Code Examples · Common Mistakes · Best Practices · Mini Challenge · Practice Questions · Answers · Takeaways
Every error message in this lecture is real. Each one was produced by running the code shown on Dart 3.12.2, so you will recognize it when you meet it yourself.
Prerequisites
Students should already understand, from Lectures 052–065:
- classes, constructors, private members, and getters
extends, overriding, andsuper- abstract classes and interfaces with
implements - polymorphism: one variable type, many object types
Connection With Previous Lectures
Lecture 059 gave us the is-a test and left one question open: "a car has an engine" — so
what do we write instead of extends? Here is what happens if we ignore the test:
class Engine {
void start() {
print('Engine running');
}
}
class Car extends Engine {}
void main() {
var car = Car();
car.start();
print(car is Engine);
}Engine running
trueIt compiles, but Dart now believes a car is an engine. And with only one superclass, where do the wheels and the radio go? This lecture shows the other way to reuse code.
Learning Objectives
By the end of this lecture you will be able to:
- Explain composition as an object holding other objects
- Use delegation to forward work to an inner object
- Recognize a fragile class hierarchy and why it breaks
- Refactor an inheritance tree into composition
- Inject behaviour objects through the constructor using an interface
- Swap an object's behaviour while the program runs
- Apply the rule "favor composition over inheritance"
- Decide when inheritance is still the right choice
1. What Is Composition?
Composition means building an object out of other objects that it keeps in its fields. The outer object has the inner ones.
Analogy — a car is assembled from parts: an engine, four wheels, a radio. A mechanic can replace the engine without rebuilding the car.
2. A Car That Has an Engine
The engine becomes a field:
class Engine {
void start() {
print('Engine running');
}
}
class Car {
final Engine engine = Engine();
void drive() {
engine.start();
print('Car moving');
}
}
void main() {
var car = Car();
car.drive();
print(car is Engine);
}Engine running
Car moving
falseThe car uses the engine's code, but is no longer an engine. It could hold a Radio and four
Wheels too — a class can have as many fields as it needs.
3. Delegation
When drive() calls engine.start(), the car delegates: it hands the job to the object
that knows how to do it. Delegation also lets you choose what to expose. Keep the part
private and forward only the methods you want:
class Battery {
int level = 40;
void charge(int amount) {
level += amount;
}
}
class Phone {
final _battery = Battery();
void plugIn() => _battery.charge(30);
int get batteryLevel => _battery.level;
}
void main() {
var phone = Phone();
phone.plugIn();
print('Battery: ${phone.batteryLevel}%');
}Battery: 70%Outside code can charge the phone and read the level, but cannot set level to 500. With
extends Battery, every public Battery member would belong to Phone.
4. The Fragile Hierarchy
Inheritance works until the requirements change. A company starts with salaried staff, and managers earn a bonus:
class Employee {
Employee(this.name);
final String name;
double pay() => 0;
}
class SalariedEmployee extends Employee {
SalariedEmployee(super.name, this.salary);
final double salary;
@override
double pay() => salary;
}
class SalariedManager extends SalariedEmployee {
SalariedManager(super.name, super.salary);
@override
double pay() => super.pay() + 500;
}
void main() {
var sara = SalariedManager('Sara', 3000);
print('${sara.name}: ${sara.pay()}');
}Sara: 3500.0Clean so far. Then three new requirements arrive:
- Hourly workers. We add
HourlyEmployeeandHourlyManager, which must copy the+ 500bonus — it cannot extend bothHourlyEmployeeandSalariedManager. - Contractors. Every role needs a third version:
ContractManager… - Sara moves to hourly pay. An object can never change its class, so her
SalariedManagermust be thrown away and rebuilt.
Employee
├── SalariedEmployee
│ └── SalariedManager
├── HourlyEmployee
│ └── HourlyManager ← bonus copied
└── ContractEmployee
└── ContractManager ← bonus copied againTwo kinds of pay × two roles needs 4 classes; three × three needs 9. The tree mixes two separate ideas — how someone is paid and what job they do — into one chain, so each change cracks it somewhere. This is a fragile hierarchy.
5. Refactoring to Composition
Pull the changing idea — how someone is paid — out into its own objects. First an interface (Lecture 063):
abstract class PayPlan {
double amount();
}
class SalaryPlan implements PayPlan {
SalaryPlan(this.salary);
final double salary;
@override
double amount() => salary;
}
class HourlyPlan implements PayPlan {
HourlyPlan(this.hours, this.rate);
final int hours;
final double rate;
@override
double amount() => hours * rate;
}Now Employee has a pay plan, and the manager bonus is just a value:
class Employee {
Employee(this.name, this.plan, {this.bonus = 0});
final String name;
final PayPlan plan;
final double bonus;
double pay() => plan.amount() + bonus;
}
void main() {
var sara = Employee('Sara', SalaryPlan(3000), bonus: 500);
var ali = Employee('Ali', HourlyPlan(80, 20));
print('${sara.name}: ${sara.pay()}');
print('${ali.name}: ${ali.pay()}');
}Sara: 3500.0
Ali: 1600.0(Run it with the PayPlan classes above.) One class replaces the tree; the bonus is written
once.
6. Injecting Behaviour Through the Constructor
In Employee('Sara', SalaryPlan(3000)) the employee does not create its own plan — we
pass one in. This is called injecting a behaviour object.
Employee only knows the PayPlan interface, so any class that implements it fits. That
is polymorphism (Lecture 065) at work: plan.amount() runs whichever version the object
has. Programmers call this design the strategy idea — the "how" is a separate object you
plug in.
7. Changing Behaviour While the Program Runs
Remove final from the field and the plan can be replaced on the same object:
class Employee {
Employee(this.name, this.plan);
final String name;
PayPlan plan;
double pay() => plan.amount();
}
void main() {
var sara = Employee('Sara', SalaryPlan(3000));
print(sara.pay());
sara.plan = HourlyPlan(100, 25);
print(sara.pay());
}3000.0
2500.0The third requirement from Section 4 — impossible with inheritance — is one assignment.
8. Adding a New Behaviour
Contractors? Write one new class. Employee does not change, and no manager variant is
needed:
class ContractPlan implements PayPlan {
ContractPlan(this.projectFee);
final double projectFee;
@override
double amount() => projectFee;
}
void main() {
var zayan = Employee('Zayan', ContractPlan(5000));
print('${zayan.name}: ${zayan.pay()}');
}Zayan: 5000.09. Favor Composition over Inheritance
This rule of thumb is famous for a reason:
Inheritance (extends) | Composition (a field) | |
|---|---|---|
| Relationship | is-a | has-a |
| How many | one superclass | any number of parts |
| Chosen | when the class is written | when the object is created |
| Can change later | never | yes, if the field is not final |
| Exposes | every public parent member | only what you forward |
When unsure, start with a field — untangling a deep tree later is painful.
10. When Inheritance Is Right
Inheritance is still the right tool when all of these are true:
- the is-a sentence is true and will stay true ("a savings account is an account");
- the child wants every parent member, not just a few;
- the hierarchy is shallow — one or two levels;
- the variation does not change while the program runs.
SavingsAccount extends Account from Lecture 061 passes all four. Mixins (Lecture 064) are
a third option, for sharing a small set of methods across unrelated classes.
11. A Real-World Example
A shopping cart composed of items and a discount rule that can be swapped at checkout:
abstract class Discount {
double apply(double total);
}
class NoDiscount implements Discount {
@override
double apply(double total) => total;
}
class PercentOff implements Discount {
PercentOff(this.percent);
final double percent;
@override
double apply(double total) => total - total * percent / 100;
}
class Item {
Item(this.name, this.price);
final String name;
final double price;
}
class Cart {
Cart(this.discount);
final _items = <Item>[];
Discount discount;
void add(Item item) => _items.add(item);
double get total {
var sum = 0.0;
for (var item in _items) {
sum += item.price;
}
return discount.apply(sum);
}
}
void main() {
var cart = Cart(NoDiscount());
cart.add(Item('Bag', 1500));
cart.add(Item('Pen', 500));
print('Total: ${cart.total}');
cart.discount = PercentOff(10);
print('With coupon: ${cart.total}');
}Total: 2000.0
With coupon: 1800.0Cart has a private item list it delegates to, and a Discount injected through the
constructor and swapped later.
Code Examples
Example 1 — Has-a with a Field
class Wheel {
void roll() => print('Wheel rolling');
}
class Bicycle {
final front = Wheel();
final back = Wheel();
void ride() {
front.roll();
back.roll();
}
}
void main() {
Bicycle().ride();
}Wheel rolling
Wheel rollingExample 2 — Delegating a Method
class Speaker {
void play(String song) => print('Playing $song');
}
class Car {
final _speaker = Speaker();
void playMusic(String song) => _speaker.play(song);
}
void main() {
Car().playMusic('Dil Dil Pakistan');
}Playing Dil Dil PakistanExample 3 — Exposing Only What You Want
class Stack {
final _items = <int>[];
void push(int value) => _items.add(value);
int pop() => _items.removeLast();
bool get isEmpty => _items.isEmpty;
}
void main() {
var stack = Stack();
stack.push(1);
stack.push(2);
print(stack.pop());
print(stack.isEmpty);
}2
falseThe private list hides insert and other methods that would break the stack's rule.
Example 4 — Injecting a Behaviour
abstract class Greeting {
String hello(String name);
}
class English implements Greeting {
@override
String hello(String name) => 'Hello, $name';
}
class Urdu implements Greeting {
@override
String hello(String name) => 'Assalam-o-Alaikum, $name';
}
class Greeter {
Greeter(this.greeting);
final Greeting greeting;
void greet(String name) => print(greeting.hello(name));
}
void main() {
Greeter(English()).greet('Ali');
Greeter(Urdu()).greet('Sara');
}Hello, Ali
Assalam-o-Alaikum, SaraExample 5 — Swapping at Runtime
abstract class Weapon {
int damage();
}
class Sword implements Weapon {
@override
int damage() => 10;
}
class Bow implements Weapon {
@override
int damage() => 7;
}
class Player {
Player(this.weapon);
Weapon weapon;
void attack() => print('Hit for ${weapon.damage()}');
}
void main() {
var zayan = Player(Sword());
zayan.attack();
zayan.weapon = Bow();
zayan.attack();
}Hit for 10
Hit for 7Example 6 — Student Marks
abstract class GradingRule {
String grade(int marks);
}
class PassFail implements GradingRule {
@override
String grade(int marks) => marks >= 50 ? 'Pass' : 'Fail';
}
class Letter implements GradingRule {
@override
String grade(int marks) => marks >= 80 ? 'A' : 'B';
}
class Student {
Student(this.name, this.marks, this.rule);
final String name;
final int marks;
final GradingRule rule;
@override
String toString() => '$name: ${rule.grade(marks)}';
}
void main() {
print(Student('Ali', 85, Letter()));
print(Student('Sara', 45, PassFail()));
}Ali: A
Sara: FailExample 7 — Bank Account with a Fee Policy
abstract class FeePolicy {
double fee(double amount);
}
class StandardFee implements FeePolicy {
@override
double fee(double amount) => amount * 0.02;
}
class NoFee implements FeePolicy {
@override
double fee(double amount) => 0;
}
class Account {
Account(this.owner, this.balance, this.policy);
final String owner;
double balance;
FeePolicy policy;
void withdraw(double amount) {
balance -= amount + policy.fee(amount);
}
}
void main() {
var ali = Account('Ali', 1000, StandardFee());
ali.withdraw(500);
print(ali.balance);
ali.policy = NoFee();
ali.withdraw(100);
print(ali.balance);
}490.0
390.0Example 8 — Temperature Display
abstract class Formatter {
String format(double celsius);
}
class CelsiusFormat implements Formatter {
@override
String format(double celsius) => '$celsius°C';
}
class FahrenheitFormat implements Formatter {
@override
String format(double celsius) => '${celsius * 9 / 5 + 32}°F';
}
class Thermometer {
Thermometer(this.formatter);
Formatter formatter;
double reading = 25.0;
void show() => print(formatter.format(reading));
}
void main() {
var t = Thermometer(CelsiusFormat());
t.show();
t.formatter = FahrenheitFormat();
t.show();
}25.0°C
77.0°FCommon Beginner Mistakes
1. Using extends for a has-a relationship
class Car extends Engine compiles, so Dart cannot warn you. If "a B is an A" sounds wrong,
make A a field.
2. Forgetting to give the part a value
class Car {
// Engine engine; // ✗ no value, and no constructor sets it
final Engine engine = Engine(); // ✓
}Error: Field 'engine' should be initialized because its type 'Engine' doesn't allow null.A part is a normal non-nullable field: set it where it is declared or in the constructor.
3. Typing the field with a concrete class
class Employee {
Employee(this.plan);
final SalaryPlan plan; // ✗ should be PayPlan
}
void main() {
// Employee(HourlyPlan(80, 20)); // ✗
}Error: The argument type 'HourlyPlan' can't be assigned to the parameter type 'SalaryPlan'.Type the field with the interface, so every implementation fits.
4. Making the behaviour final, then swapping it
class Player {
Player(this.weapon);
final Weapon weapon;
}
void main() {
var zayan = Player(Sword());
// zayan.weapon = Bow(); // ✗
}Error: The setter 'weapon' isn't defined for the type 'Player'.Remove final when the behaviour must be swappable.
Best Practices
- Apply the is-a test before every
extends; use a field for has-a. - Put the idea that changes most into its own interface and classes.
- Type injected fields with the interface, not a concrete class.
- Pass behaviour objects in through the constructor instead of creating them inside.
- Keep inheritance shallow and save it for true, stable is-a relationships.
Mini Challenge
Create an interface Notifier with void send(String message), and two classes:
EmailNotifier prints Email: <message>, and SmsNotifier prints SMS: <message>. Create
an Order that receives a Notifier through its constructor and has place(String item),
which sends Order placed: <item>. Place an order for a Bag by email, switch the notifier to
SMS, and place an order for a Pen.
Your program should print exactly:
Email: Order placed: Bag
SMS: Order placed: PenTry it yourself before opening the solution.
Show solution
abstract class Notifier {
void send(String message);
}
class EmailNotifier implements Notifier {
@override
void send(String message) => print('Email: $message');
}
class SmsNotifier implements Notifier {
@override
void send(String message) => print('SMS: $message');
}
class Order {
Order(this.notifier);
Notifier notifier;
void place(String item) => notifier.send('Order placed: $item');
}
void main() {
var order = Order(EmailNotifier());
order.place('Bag');
order.notifier = SmsNotifier();
order.place('Pen');
}Email: Order placed: Bag
SMS: Order placed: PenPractice Questions
Work through these in order — they run from beginner to application to challenge.
Beginner
- Create
Enginewithstart()printingVroom. CreateCarthat has an engine and adrive()method that starts it, then printsDriving. - For each pair, say "is-a" or "has-a": Student / Person, House / Room, Laptop / Battery, Dog / Animal.
- Create
Librarywith a privateList<String>of books,addBook(String title), and a gettercount. Add two books and print2.
Application
- Create an interface
Shapewithdouble area(), andSquareandCircle(use3.14). CreateRoomthat has aShape floorand a getterfloorArea. Print the area of aRoom(Square(4)). - Create an interface
SoundwithString make(), andBarkandMeow. CreatePetthat receives aSoundand hasspeak()printing it. Make one dog and one cat. - Using the
PlayerandWeaponclasses from Example 5, add aHammerwith damage15, and switch Zayan to it after his first attack. - Without running it: in Section 4, why can't
HourlyManagerreuse the bonus code fromSalariedManager?
Challenge
- Create an interface
TaxRulewithdouble tax(double price). WriteNoTaxandSalesTax(17%). AProducthas a name, a price and aTaxRule, and a getterfinalPrice. PrintBook: 1000.0andPhone: 11700.0. - A school has
Teacher,PartTimeTeacher,PrincipalandPartTimePrincipal. Redesign it with composition so it needs oneStaffclass. PrintSara: 4500.0(full-time 4000 plus principal bonus 500) andAli: 1200.0(part-time, 60 hours at 20).
Practice Answers
Question 1 — Solution
class Engine {
void start() => print('Vroom');
}
class Car {
final engine = Engine();
void drive() {
engine.start();
print('Driving');
}
}
void main() {
Car().drive();
}Vroom
DrivingQuestion 2 — Solution
A student is a person; a house has rooms; a laptop has a battery; a dog is an animal.
Question 3 — Solution
class Library {
final _books = <String>[];
void addBook(String title) => _books.add(title);
int get count => _books.length;
}
void main() {
var library = Library();
library.addBook('Dart Basics');
library.addBook('Clean Code');
print(library.count);
}2Question 4 — Solution
abstract class Shape {
double area();
}
class Square implements Shape {
Square(this.side);
final double side;
@override
double area() => side * side;
}
class Circle implements Shape {
Circle(this.radius);
final double radius;
@override
double area() => 3.14 * radius * radius;
}
class Room {
Room(this.floor);
final Shape floor;
double get floorArea => floor.area();
}
void main() {
print(Room(Square(4)).floorArea);
}16.0Question 5 — Solution
abstract class Sound {
String make();
}
class Bark implements Sound {
@override
String make() => 'Woof';
}
class Meow implements Sound {
@override
String make() => 'Meow';
}
class Pet {
Pet(this.sound);
final Sound sound;
void speak() => print(sound.make());
}
void main() {
Pet(Bark()).speak();
Pet(Meow()).speak();
}Woof
MeowQuestion 6 — Solution
Add this class to Example 5 and change main:
class Hammer implements Weapon {
@override
int damage() => 15;
}
void main() {
var zayan = Player(Sword());
zayan.attack();
zayan.weapon = Hammer();
zayan.attack();
}Hit for 10
Hit for 15Question 7 — Solution
A Dart class has one superclass. HourlyManager extends HourlyEmployee for hourly pay, so
it cannot also extend SalariedManager — the bonus must be copied.
Question 8 — Solution
abstract class TaxRule {
double tax(double price);
}
class NoTax implements TaxRule {
@override
double tax(double price) => 0;
}
class SalesTax implements TaxRule {
@override
double tax(double price) => price * 0.17;
}
class Product {
Product(this.name, this.price, this.rule);
final String name;
final double price;
final TaxRule rule;
double get finalPrice => price + rule.tax(price);
}
void main() {
var products = [
Product('Book', 1000, NoTax()),
Product('Phone', 10000, SalesTax()),
];
for (var p in products) {
print('${p.name}: ${p.finalPrice}');
}
}Book: 1000.0
Phone: 11700.0Question 9 — Solution
abstract class PayPlan {
double amount();
}
class FullTime implements PayPlan {
FullTime(this.salary);
final double salary;
@override
double amount() => salary;
}
class PartTime implements PayPlan {
PartTime(this.hours, this.rate);
final int hours;
final double rate;
@override
double amount() => hours * rate;
}
class Staff {
Staff(this.name, this.plan, {this.bonus = 0});
final String name;
final PayPlan plan;
final double bonus;
double pay() => plan.amount() + bonus;
}
void main() {
var sara = Staff('Sara', FullTime(4000), bonus: 500);
var ali = Staff('Ali', PartTime(60, 20));
print('${sara.name}: ${sara.pay()}');
print('${ali.name}: ${ali.pay()}');
}Sara: 4500.0
Ali: 1200.0Full-time or part-time is the plan; teacher or principal is the bonus.
Quick Revision
- What is composition?
- What does delegation mean?
- Why does a deep hierarchy break when a second kind of variation appears?
- What does it mean to inject a behaviour object?
- Why should an injected field use the interface type?
- How do you let an object change its behaviour while the program runs?
- When is inheritance still the right choice?
Key Takeaways
- Composition builds an object from other objects held in its fields — a has-a relationship.
- Delegation forwards work to an inner object and exposes only what you choose.
- A deep tree that mixes two ideas multiplies classes, copies code, and cannot change an object's class later — a fragile hierarchy.
- Refactor by moving the changing idea into an interface with small implementing classes.
- Inject behaviour through the constructor and type the field with the interface.
- A non-
finalbehaviour field can be swapped while the program runs. - Favor composition over inheritance; use
extendsfor true, stable, shallow is-a relationships.
What's Next?
Lecture 67 — Enums. That closes Advanced OOP. Many values come from a small fixed set — a pay type, an order status, a weekday — and so far we have stored them as strings that a typo can break. Next we will learn how enums give such a set its own type, so Dart checks every value for us.