Object-Oriented Programming in Dart: Classes, Inheritance, Polymorphism, and Encapsulation

Object-Oriented Programming in Dart

Object-Oriented Programming in Dart

I came to Dart from a background scattered across Java, JavaScript, and a bit of Python, and one of the things that struck me quickly is how seriously Dart takes object orientation while still staying pragmatic. Everything in Dart is an object, even numbers and functions, and the language gives you a genuinely complete OOP toolkit: classes, single inheritance, interfaces (implicit, through implements), mixins, abstract classes, and access control through library-level privacy.

This article is my attempt to lay out the whole picture — not just the syntax, but why each piece exists and where I actually reach for it in real projects, including Flutter.

Table of Contents

  1. Classes: The Foundation
  2. Encapsulation and Privacy in Dart
  3. Inheritance with extends
  4. The super Keyword and Method Overriding
  5. Polymorphism in Practice
  6. Abstract Classes
  7. Interfaces via implements
  8. Mixins with mixin and with
  9. Composing Behavior: extends vs implements vs with
  10. Static Members and Class-Level State
  11. Operator Overloading
  12. Null Safety and OOP
  13. Internal Working: How Dart Resolves Method Calls
  14. Real-World and Flutter Use Cases
  15. Best Practices and Common Mistakes
  16. Debugging OOP Issues
  17. FAQs
  18. Summary and References

1. Classes: The Foundation

A class in Dart bundles state (fields) and behavior (methods) into a single reusable blueprint.

class Animal {
  String name;
  int age;

  Animal(this.name, this.age);

  void describe() {
    print('$name is $age years old');
  }
}

void main() {
  var dog = Animal('Rex', 3);
  dog.describe();
}

Output:

Rex is 3 years old

Every class implicitly extends Object, which is why methods like toString(), hashCode, and == are available on every Dart object without you writing anything.

void main() {
  var animal = Animal('Rex', 3);
  print(animal.toString());
  print(animal is Object);
}

Output:

Instance of 'Animal'
true

2. Encapsulation and Privacy in Dart

This is where Dart differs meaningfully from Java or C#. Dart doesn’t have private, protected, or public keywords. Instead, privacy is enforced at the library (file) level: any identifier prefixed with an underscore _ is only accessible within the same Dart file (technically, the same library).

class BankAccount {
  double _balance = 0;

  void deposit(double amount) {
    if (amount > 0) {
      _balance += amount;
    }
  }

  double get balance => _balance;
}

void main() {
  var account = BankAccount();
  account.deposit(500);
  print('Balance: ${account.balance}');
  // account._balance += 1000; // Compile-time error outside this library
}

Output:

Balance: 500.0

I like this design once I got used to it — it means encapsulation is enforced at the file/module boundary rather than the class boundary, which pairs well with how Dart projects are typically organized (one class, or a small tightly related group of classes, per file).

Getters and Setters

Dart lets you expose computed properties that look like plain field access at the call site but run actual logic underneath.

class Temperature {
  double _celsius;

  Temperature(this._celsius);

  double get fahrenheit => (_celsius * 9 / 5) + 32;

  set fahrenheit(double value) {
    _celsius = (value - 32) * 5 / 9;
  }

  double get celsius => _celsius;
}

void main() {
  var temp = Temperature(25);
  print('${temp.celsius}°C = ${temp.fahrenheit}°F');

  temp.fahrenheit = 98.6;
  print('${temp.fahrenheit}°F = ${temp.celsius.toStringAsFixed(1)}°C');
}

Output:

25.0°C = 77.0°F
98.6°F = 37.0°C

This is a genuinely useful encapsulation pattern: callers write temp.fahrenheit = 98.6 like a normal field assignment, but internally it’s converting and storing as Celsius. The internal representation can change entirely without breaking any code that consumes this class.

3. Inheritance with extends

Dart supports single inheritance — a class can extend exactly one superclass, inheriting its fields and methods.

class Vehicle {
  String brand;
  Vehicle(this.brand);

  void start() {
    print('$brand vehicle starting...');
  }
}

class Car extends Vehicle {
  int doors;
  Car(String brand, this.doors) : super(brand);

  void honk() {
    print('$brand car honking!');
  }
}

void main() {
  var car = Car('Toyota', 4);
  car.start(); // Inherited from Vehicle
  car.honk();  // Defined in Car
}

Output:

Toyota vehicle starting...
Toyota car honking!

Notice : super(brand) — since Vehicle has no default no-argument constructor (it requires a brand), Car‘s constructor must explicitly forward that value up the chain.

4. The super Keyword and Method Overriding

super lets a subclass reach into its parent’s implementation, which is essential when you want to extend behavior rather than fully replace it.

class Shape {
  void render() {
    print('Rendering a generic shape');
  }
}

class Square extends Shape {
  @override
  void render() {
    super.render();
    print('Rendering a square specifically');
  }
}

void main() {
  Square().render();
}

Output:

Rendering a generic shape
Rendering a square specifically

The @override annotation isn’t strictly required by the compiler, but I never skip it — it makes the analyzer verify that I’m actually overriding a real superclass member, catching typos like redner() instead of render() at analysis time instead of letting a silent new method slip in unnoticed.

5. Polymorphism in Practice

Polymorphism is where OOP starts paying off in real code — writing logic against a general type and letting the runtime dispatch to the correct specific behavior.

abstract class Shape {
  double area();
  void describe() {
    print('This shape has an area of ${area().toStringAsFixed(2)}');
  }
}

class Circle extends Shape {
  final double radius;
  Circle(this.radius);
  @override
  double area() => 3.14159 * radius * radius;
}

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

void main() {
  List<Shape> shapes = [Circle(5), Rectangle(4, 6)];

  for (var shape in shapes) {
    shape.describe();
  }
}

Output:

This shape has an area of 78.54
This shape has an area of 24.00

Notice that describe() is defined once, on the abstract Shape class, but it calls area() — and at runtime, Dart resolves area() to whichever concrete subclass’s implementation actually applies. This is dynamic dispatch, and it’s the mechanism that makes polymorphism useful rather than just a type-system curiosity: I can add a Triangle class next month, and the existing describe() loop over List<Shape> doesn’t need to change at all.

6. Abstract Classes

An abstract class can’t be instantiated directly — it exists purely to define a shared contract (and optionally, some shared implementation) for its subclasses.

abstract class PaymentMethod {
  void processPayment(double amount);

  void logTransaction(double amount) {
    print('Logging transaction of \$${amount.toStringAsFixed(2)}');
  }
}

class CreditCardPayment extends PaymentMethod {
  @override
  void processPayment(double amount) {
    print('Processing \$${amount.toStringAsFixed(2)} via credit card');
    logTransaction(amount);
  }
}

void main() {
  // var p = PaymentMethod(); // Compile-time error — cannot instantiate abstract class
  PaymentMethod payment = CreditCardPayment();
  payment.processPayment(150.0);
}

Output:

Processing $150.00 via credit card
Logging transaction of $150.00

I use abstract classes whenever I want to guarantee a method exists across a family of related classes, while also sharing some default behavior (logTransaction here) that every subclass gets for free.

7. Interfaces via implements

Dart has no separate interface keyword — instead, every class implicitly defines an interface, and any class can implements that interface, promising to provide concrete implementations of every method and getter/setter, without inheriting any actual code.

class Flyable {
  void fly() {
    print('Flying generically');
  }
}

class Swimmable {
  void swim() {
    print('Swimming generically');
  }
}

class Duck implements Flyable, Swimmable {
  @override
  void fly() {
    print('Duck flying low over the pond');
  }

  @override
  void swim() {
    print('Duck paddling on the water');
  }
}

void main() {
  var duck = Duck();
  duck.fly();
  duck.swim();
}

Output:

Duck flying low over the pond
Duck paddling on the water

This is the key distinction I remind myself of constantly: implements gives you multiple interface inheritance (a class can implements many things) but zero code reuse — every method must be reimplemented from scratch, even if the original class had a body. extends gives you the opposite: one superclass, but full code reuse via inheritance.

8. Mixins with mixin and with

Mixins solve the gap between extends (one parent, full reuse) and implements (many parents, no reuse): a mixin lets you reuse a chunk of code across multiple unrelated class hierarchies without needing traditional multiple inheritance.

mixin Logger {
  void log(String message) {
    print('[LOG]: $message');
  }
}

mixin Timestamped {
  DateTime get createdAt => DateTime.now();
}

class Order with Logger, Timestamped {
  final String id;
  Order(this.id);

  void placeOrder() {
    log('Order $id placed at $createdAt');
  }
}

void main() {
  var order = Order('ORD-001');
  order.placeOrder();
}

Output (timestamp will vary):

[LOG]: Order ORD-001 placed at 2026-07-29 10:15:32.123456

I reach for mixins whenever I have cross-cutting behavior — logging, validation, serialization helpers — that logically belongs to several unrelated classes at once, rather than fitting naturally into a single inheritance chain.

Restricting Mixins with on

You can constrain a mixin so it can only be mixed into classes that already extend or implement a particular type, which lets the mixin safely call methods it assumes exist.

class Animal {
  String name = 'Animal';
}

mixin Trainable on Animal {
  void train() {
    print('$name is being trained');
  }
}

class Dog extends Animal with Trainable {
  Dog(String name) {
    this.name = name;
  }
}

void main() {
  var dog = Dog('Rex');
  dog.train();
}

Output:

Rex is being trained

9. Composing Behavior: extends vs implements vs with

I get asked about this distinction constantly, so here’s the table I keep in my head:

KeywordReuses code?How many?Purpose
extendsYesOne superclass only“Is-a” relationship, full inheritance
implementsNoMany interfacesContract only, forces reimplementation
with (mixin)YesMany mixinsReusable behavior across unrelated hierarchies

A single class can combine all three:

class Base {
  void baseMethod() => print('Base method');
}

mixin Flying {
  void fly() => print('Flying');
}

abstract class Reportable {
  void report();
}

class SuperCar extends Base with Flying implements Reportable {
  @override
  void report() => print('SuperCar reporting status: OK');
}

void main() {
  var car = SuperCar();
  car.baseMethod();
  car.fly();
  car.report();
}

Output:

Base method
Flying
SuperCar reporting status: OK

10. Static Members and Class-Level State

Static fields and methods belong to the class itself, not to any individual instance — shared across every instance and accessible without creating one.

class Counter {
  static int _count = 0;

  Counter() {
    _count++;
  }

  static int get count => _count;
}

void main() {
  Counter();
  Counter();
  Counter();
  print('Instances created: ${Counter.count}');
}

Output:

Instances created: 3

I use static members for constants, utility functions that don’t need instance state, and simple counters/registries like this example. One caveat: static members are not inherited in the way instance members are conceptually “overridden” — subclasses get their own separate static namespace, and static methods use static (compile-time) dispatch, not the dynamic dispatch that makes polymorphism work for instance methods.

11. Operator Overloading

Dart allows you to redefine how operators like +, ==, <, and [] behave for your own classes, which makes custom types feel like first-class citizens of the language.

class Money {
  final int cents;
  Money(this.cents);

  Money operator +(Money other) => Money(cents + other.cents);
  Money operator -(Money other) => Money(cents - other.cents);

  @override
  bool operator ==(Object other) => other is Money && other.cents == cents;

  @override
  int get hashCode => cents.hashCode;

  @override
  String toString() => '\$${(cents / 100).toStringAsFixed(2)}';
}

void main() {
  var price1 = Money(1500);
  var price2 = Money(250);

  var total = price1 + price2;
  print('Total: $total');
  print(Money(500) == Money(500));
}

Output:

Total: $17.50
true

As covered in the collections article, always override == and hashCode together — the moment you customize equality, hash-based collections (Set, Map keys) depend on both being consistent.

12. Null Safety and OOP

Sound null safety fundamentally changed how I design class hierarchies. Fields, parameters, and return types are non-nullable by default, and the compiler enforces initialization.

class Profile {
  String username;      // must always have a value
  String? bio;           // explicitly optional

  Profile(this.username, {this.bio});

  String get displayBio => bio ?? 'No bio provided';
}

void main() {
  var p1 = Profile('ahsan_dev');
  var p2 = Profile('sara_codes', bio: 'Flutter developer');

  print(p1.displayBio);
  print(p2.displayBio);
}

Output:

No bio provided
Flutter developer

late for Deferred Initialization

Sometimes a field genuinely can’t be initialized in the constructor (for example, it depends on initState() in a Flutter StatefulWidget), but you still want it non-nullable. late defers the null-safety check to first access.

class Repository {
  late final String apiKey;

  void configure(String key) {
    apiKey = key;
  }

  void fetchData() {
    print('Fetching with key: $apiKey');
  }
}

void main() {
  var repo = Repository();
  repo.configure('secret-123');
  repo.fetchData();
}

Output:

Fetching with key: secret-123

If fetchData() were called before configure(), Dart would throw a LateInitializationError at runtime — a much clearer failure than a silent null bug.

13. Internal Working: How Dart Resolves Method Calls

Understanding dispatch mechanics clarified a lot of “why does this behave this way” moments for me.

class Animal {}
mixin Trainable {
  void train() => print('training');
}
class Dog extends Animal with Trainable {}

void main() {
  var dog = Dog();
  print(dog is Animal);     // true
  print(dog is Trainable);  // true
}

Output:

true
true

14. Real-World and Flutter Use Cases

Abstract Repository Pattern

abstract class UserRepository {
  Future<String> getUserName(String id);
}

class ApiUserRepository implements UserRepository {
  @override
  Future<String> getUserName(String id) async {
    // Simulated network call
    await Future.delayed(Duration(milliseconds: 200));
    return 'User_$id';
  }
}

class FakeUserRepository implements UserRepository {
  @override
  Future<String> getUserName(String id) async => 'TestUser';
}

This pattern — coding against an abstract interface and swapping implementations — is exactly how I write testable Flutter apps. Production code depends on ApiUserRepository; tests inject FakeUserRepository instead, with zero changes to the code that consumes UserRepository.

StatefulWidget as Inheritance in Practice

Every Flutter StatefulWidget you write is inheritance and polymorphism at work — Flutter’s framework calls build() on your State subclass polymorphically, without knowing anything about your specific widget in advance.

// Conceptual structure, not runnable outside a Flutter project
class CounterWidget extends StatefulWidget {
  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Text('Count: $_count');
  }
}

Mixins in Flutter

SingleTickerProviderStateMixin for animations, AutomaticKeepAliveClientMixin for preserving list item state — these are exactly the mixin pattern from section 8, applied by the Flutter framework itself.

15. Best Practices and Common Mistakes

  1. Favor composition (mixins, has-a relationships) over deep inheritance chains. Three or more levels of extends tends to become hard to reason about — I try to keep hierarchies shallow.
  2. Use abstract classes to define contracts, not just to share code. If a method genuinely must be implemented by every subclass, declare it in the abstract class without a body.
  3. Don’t forget @override. It costs nothing and catches real bugs.
  4. Be deliberate about implements vs extends. If you implements a concrete class, you must reimplement every single one of its methods, even ones you wanted to keep unchanged — a very common surprise for developers new to Dart.
  5. Keep mixins focused on one responsibility. A mixin that does five unrelated things is a sign it should be split up.
  6. Use on constraints for mixins that call methods from the base type — it documents the requirement and gives you compile-time safety.
// Common mistake: implementing a concrete class instead of extending it,
// then being surprised every method needs reimplementation.
class Base {
  void greet() => print('Hello from Base');
  void farewell() => print('Goodbye from Base');
}

class Bad implements Base {
  @override
  void greet() => print('Hi!');
  // Forgetting farewell() here is a compile-time error with implements,
  // whereas extends would have inherited it automatically.
  @override
  void farewell() => print('Bye!');
}

16. Debugging OOP Issues

17. FAQs

Q: Does Dart support multiple inheritance? Not in the traditional sense of extends. But mixins give you most of the practical benefit of multiple inheritance — reusing code across unrelated hierarchies — without the classic diamond-problem ambiguity, because mixin resolution order is deterministic (last-applied wins).

Q: What’s the real difference between abstract class and mixin? An abstract class can’t be instantiated but participates in a single-inheritance extends chain and can have a constructor. A mixin cannot have a constructor of its own and is designed specifically to be combined with other classes via with, often across otherwise-unrelated hierarchies.

Q: When should I use implements instead of extends? Use implements when you want to guarantee a class fulfills a certain contract/shape without inheriting any implementation — this is especially common when writing test doubles/mocks that need to match a production class’s interface exactly.

Q: Is Dart’s privacy (underscore prefix) as strong as Java’s private keyword? It’s enforced by the compiler and is genuinely inaccessible from outside the library file, so functionally yes — but the scope boundary is the file/library, not the class, which is the key conceptual difference from Java.

Q: Can a mixin have its own fields? Yes, mixins can declare fields, though those fields get added to whatever class uses the mixin — just remember a mixin can’t have a constructor, so any field needs a default value or must be set by the consuming class.

18. Summary

Dart’s object-oriented model is deliberately layered: classes and extends give you classic single inheritance and dynamic dispatch, implements gives you interface-style contracts without accidental code coupling, and mixins fill the real-world gap where you need to share behavior across otherwise unrelated classes. Combined with library-level encapsulation, sound null safety enforced at the field level, and operator overloading for expressive custom types, Dart gives you essentially everything a mature OOP language offers, minus the ceremony that languages like Java require. Once these pieces click together, designing a clean, testable, extensible class hierarchy — whether for a backend service or a Flutter app’s widget and repository layers — becomes second nature.

References

Exit mobile version