Skip to content

01 · OOP Deep Dive

Level 1 covered basic classes: fields, constructors, getters, and simple inheritance with extends. Real Dart programs — and the Flutter framework itself — lean heavily on three more tools for shaping object relationships: abstract classes (define a contract with some shared code), mixins (share behavior across unrelated class hierarchies), and interfaces (Dart has no interface keyword — every class is an interface). Knowing when to reach for each one is what separates "code that compiles" from code that's actually maintainable as it grows.

Abstract classes

An abstract class can't be instantiated directly. It exists to define a shared contract — some methods it leaves unimplemented (abstract) and, unlike a pure interface, it can also hold real fields and concrete methods that subclasses inherit for free.

abstract class Shape {
  // Abstract classes can hold real state and concrete methods...
  final String label;
  Shape(this.label);

  // ...but a method with no body is abstract: subclasses must implement it.
  double area();

  // Concrete methods can call the abstract ones -- polymorphism at work.
  void describe() {
    print('$label has area ${area().toStringAsFixed(2)}');
  }
}

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

  @override
  double area() => 3.14159 * radius * radius;
}

class Square extends Shape {
  final double side;
  Square(this.side) : super('Square');

  @override
  double area() => side * side;
}

void main() {
  final shapes = <Shape>[Circle(2), Square(3)];
  for (final shape in shapes) {
    shape.describe();
  }
  // Circle has area 12.57
  // Square has area 9.00

  // Shape s = Shape('x'); // Error: abstract classes can't be instantiated directly
}

Use an abstract class when subclasses share both a contract and some common implementation (here, describe() is written once and reused by every shape).

Interfaces — every class is one for free

Dart has no separate interface keyword. Instead, every class implicitly defines an interface consisting of its public members. Any class can commit to that shape with implements, but — this is the key trap for people coming from Java or C# — implements copies zero implementation. Every member has to be rewritten from scratch, even ones that had a body in the original class.

class JsonSerializable {
  Map<String, dynamic> toJson() => {};
}

class User implements JsonSerializable {
  final String name;
  final int age;
  User(this.name, this.age);

  @override
  Map<String, dynamic> toJson() => {'name': name, 'age': age};
}

void main() {
  final user = User('Ada', 30);
  print(user.toJson());   // {name: Ada, age: 30}
}

If JsonSerializable.toJson() had real logic you wanted to reuse, implements would be the wrong tool — you'd want extends (single inheritance) or a mixin (see below).

Keyword Inherits implementation? How many can you use?
extends Yes, from one superclass One
implements No — contract only Many
with (mixin) Yes, from each mixin Many

Mixins — shared behavior without a shared ancestor

A mixin packages up behavior that unrelated classes can "mix in" using with, without forcing them into a single-inheritance chain. It's Dart's answer to "I want to share this method across a Duck and a Robot, but they have nothing else in common."

mixin Flyer {
  void fly() => print('Flying through the air');
}

mixin Swimmer {
  void swim() => print('Swimming through water');
}

// A class can mix in multiple behaviors without inheriting from a shared base.
class Duck with Flyer, Swimmer {
  void quack() => print('Quack!');
}

void main() {
  final duck = Duck();
  duck.fly();     // Flying through the air
  duck.quack();   // Quack!
  duck.swim();    // Swimming through water
}

The trap: mixin order determines method resolution

If two mixed-in mixins define the same method, Dart doesn't error — it resolves the conflict by linearizing the mixins right-to-left, so the rightmost mixin in the with clause wins, because it sits closest to the class in the resulting chain.

mixin LoudLogger {
  void log(String msg) => print('[LOUD] ${msg.toUpperCase()}');
}

mixin QuietLogger {
  void log(String msg) => print('[quiet] $msg');
}

class ServiceA with LoudLogger, QuietLogger {}
class ServiceB with QuietLogger, LoudLogger {}

void main() {
  ServiceA().log('starting up');   // [quiet] starting up  -- QuietLogger is rightmost
  ServiceB().log('starting up');   // [LOUD] STARTING UP   -- LoudLogger is rightmost
}

This is a genuinely common source of "why did my override not take effect" bugs — if two mixins collide, swapping their order in the with clause silently changes behavior with no warning from the analyzer.

Restricting a mixin with on

A mixin can require that it only be used on classes that already have a particular supertype, using on. This lets the mixin safely call members it knows must exist, without duck-typing risk.

abstract class Animal {
  String get name;
}

mixin Hunter on Animal {
  void hunt() => print('$name is hunting');
}

class Lion extends Animal with Hunter {
  @override
  final String name;
  Lion(this.name);
}

void main() {
  final lion = Lion('Leo');
  lion.hunt();   // Leo is hunting

  // class Bird with Hunter {} // Error: Bird doesn't extend/implement Animal
}

Choosing between them

Need Reach for
Share both state and behavior down a natural "is-a" hierarchy abstract class + extends
Guarantee a class has certain methods, regardless of ancestry implements
Share reusable behavior across otherwise-unrelated classes mixin + with
Share behavior, but only for classes that already have specific members mixin ... on SomeType

A single class can combine all three: class Lion extends Animal with Hunter implements Comparable<Lion> { ... } — extend one base, mix in one or more behaviors, and commit to one or more interfaces.

How It Actually Works

The reason "every class is an interface for free" in Dart is a language design choice with real compile-time consequences: Dart doesn't have a separate interface keyword because every class declaration implicitly generates two things — a concrete implementation, and an abstract interface (its full public member signature set) that implements can bind to. When you write class Duck implements Flyer, the compiler checks Duck against Flyer's member signatures structurally and requires Duck to provide its own implementation of every one — none of Flyer's method bodies are inherited, only the shape.

Mixins are resolved via linearization: when a class does class C extends Base with M1, M2, the compiler builds a synthetic inheritance chain — Base -> Base+M1 -> Base+M1+M2 -> C — where each mixin application creates an anonymous intermediate class inserted into the actual superclass chain. This is why super.someMethod() inside a mixin calls whatever came immediately before it in that linearized chain (which could be another mixin), not necessarily the "real" base class — mixin method resolution is genuinely dynamic with respect to application order, which is also why the order you list mixins in with M1, M2 changes behavior when both define the same method.

Abstract classes are enforced purely by the analyzer/compiler at compile time — there's no runtime "abstract" flag stopping instantiation in the way, say, a runtime reflection check would. abstract class Shape simply produces a class descriptor with no allocation entry point for unimplemented abstract members; attempting Shape() is rejected before your program ever runs, not caught by an exception at runtime.

Exercise

Model a small plugin system: an abstract class Plugin with an abstract method String get name and a concrete method void log(String msg) that prints '[$name] $msg'. Create two mixins, Cacheable (adds a Map<String, dynamic> cache = {} field and a void remember(String key, dynamic value) method) and Loggable (adds a void trace(String msg) that calls log — constrain it with on Plugin so it can). Build a class ApiPlugin extends Plugin with Cacheable, Loggable that implements name, and in main() create an instance, call remember, trace, and print the cache contents.