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.

New writing, now and then

Essays on Flutter, Dart and the decisions behind real systems. No schedule, no filler.

One click to unsubscribe, in every email.