Skip to content
← writing

February 25, 2025 · 4 min read

The Pillars of OOP in Dart

Encapsulation, inheritance, polymorphism, abstraction — plus interfaces, mixins, and composition. Each pillar of object-oriented Dart, with a concrete example.

Once you can write a class, the next step is relating classes to one another. That's what the pillars of OOP are about. Dart supports the classic four — encapsulation, inheritance, polymorphism, abstraction — plus first-class tools like interfaces, mixins, and composition. Here's each with a concrete example.

Encapsulation

Hide internal state and expose a controlled surface. In Dart, a leading underscore makes a member library-private.

class BankAccount {
  BankAccount(this._balance);
  double _balance; // private — not accessible outside this library

  double get balance => _balance; // read-only view

  void deposit(double amount) {
    if (amount <= 0) throw ArgumentError('amount must be positive');
    _balance += amount;
  }
}

final acc = BankAccount(100);
acc.deposit(50);
print(acc.balance); // 150 — but you can't set _balance directly

Encapsulation lets you enforce invariants: there's no way to reach an invalid balance from outside.

Inheritance

extends lets a class reuse and specialize another. super calls the parent; @override replaces its behavior.

class Animal {
  Animal(this.name);
  final String name;
  void describe() => print('$name is an animal');
}

class Dog extends Animal {
  Dog(super.name);

  @override
  void describe() {
    super.describe();          // reuse the parent
    print('$name is a dog');   // then specialize
  }
}

Dog('Rex').describe(); // Rex is an animal / Rex is a dog

Use inheritance sparingly — it's the tightest coupling in OOP. Prefer it only for genuine "is-a" relationships.

Polymorphism

Code written against a base type works with any subtype. The right method is chosen at runtime — dynamic dispatch.

void makeItSpeak(Animal a) => a.describe(); // knows only Animal

makeItSpeak(Dog('Rex'));   // runs Dog.describe
makeItSpeak(Animal('??')); // runs Animal.describe

One function, many behaviors, chosen by the object's actual type. This is what makes OOP extensible.

Abstraction

An abstract class defines what without how. It can't be instantiated; subclasses must fill in the abstract methods.

abstract class Shape {
  double area(); // no body — subclasses must implement
  void printArea() => print('Area is ${area()}'); // shared behavior
}

class Rectangle extends Shape {
  Rectangle(this.w, this.h);
  final double w, h;
  @override
  double area() => w * h;
}

Rectangle(3, 4).printArea(); // Area is 12

Interfaces

Dart has no separate interface keyword — every class defines an implicit interface. Use implements to promise a type's API without inheriting its code. abstract interface class makes the intent explicit.

abstract interface class Logger {
  void log(String message);
}

class ConsoleLogger implements Logger {
  @override
  void log(String message) => print('[LOG] $message');
}

class SilentLogger implements Logger {
  @override
  void log(String message) {} // does nothing
}

Now any function can accept a Logger and callers choose the implementation — the foundation of the Dependency Inversion principle.

Mixins

A mixin injects reusable behavior into many classes without inheritance. Add it with with.

mixin Swimmer {
  void swim() => print('swimming');
}
mixin Flyer {
  void fly() => print('flying');
}

class Duck extends Animal with Swimmer, Flyer {
  Duck(super.name);
}

final d = Duck('Donald');
d.swim(); // swimming
d.fly();  // flying

Mixins solve the "I need this behavior in several unrelated classes" problem that single inheritance can't.

Composition

Instead of inheriting, a class can hold other objects and delegate to them — a "has-a" relationship. Composition is more flexible than inheritance because you can swap the parts.

class Engine {
  void start() => print('engine started');
}

class Car {
  Car(this._engine);
  final Engine _engine; // Car HAS-A Engine

  void drive() {
    _engine.start();
    print('driving');
  }
}

Car(Engine()).drive();

The well-worn advice "favor composition over inheritance" holds in Dart too: composition keeps classes small, decoupled, and easy to test.

Putting it together

These aren't competing techniques — you use them together. Encapsulate state, define behavior behind interfaces, share cross-cutting logic with mixins, compose objects from smaller ones, and reserve inheritance for true specialization. That balance is what separates code that ages well from code that calcifies.