Interfaces, Mixins & Composition

Dart’s approach to multiple capabilities is one of its most elegant features — and one of the biggest surprises for developers coming from Java or C#. Through implicit interfacesmixins, and composition, Dart gives you surgical control over what a class is, what it can do, and what it contains — without the tangled hierarchies that plague languages with only single inheritance.


1. The four pillars — completing the set

Abstraction — hiding how something works behind what it does — is the final pillar. Interfaces are abstraction’s purest form: a contract with no implementation at all, just a list of capabilities a class promises to provide. This article completes the four pillars and introduces two uniquely Dart features — implicit interfaces and mixins — that make the language’s OOP model especially expressive.


2. The multiple-capability problem

Consider what a fully-featured TextMessage in a production chat app needs to do:

It needs to be a Message — so it fits into the message hierarchy. It needs to be Serializable — so it can be saved to a database and sent over the network. It needs to be Cacheable — so it can be stored locally and retrieved instantly. And it needs to be Searchable — so users can find it by keyword.

Each of these is a distinct capability. They don’t naturally form a single hierarchy. And Dart (like Java) does not allow extends to be used with more than one class:

// ❌ Dart does not allow multiple inheritance via extends
class TextMessage extends Message, Serializable, Cacheable {
  // Compile error: only one superclass allowed
}

The solution is Dart’s two complementary mechanisms: implements for declaring what a class promises to do, and mixin + with for sharing how to do it across unrelated classes. Let’s learn each one.


3. Dart’s implicit interfaces

Here is Dart’s first surprise for developers coming from Java or C#: there is no interface keyword in Dart.

Instead, every class in Dart automatically defines an interface. The interface of a class is simply the set of all its public methods and getters — the contract it exposes, stripped of any implementation. Any other class can implement that implicit interface and is then required to provide its own concrete implementation of every member.

// serializable.dart

// A regular class — but it also implicitly defines an interface
class Serializable {
  Map<String, dynamic> toJson() => {};
}

// Using Serializable as an interface via 'implements'
class Product implements Serializable {
  final String name;
  final double price;

  Product(this.name, this.price);

  // Must provide our own implementation — even though Serializable had one
  @override
  Map<String, dynamic> toJson() => {
    'name': name,
    'price': price,
  };
}

Notice the crucial rule: when you implement a class, you get none of its implementation. Even though Serializable had a body for toJson()Product must write its own version. The implements relationship is purely a contract — a type promise. It gives you no free code.

Best practice: use abstract classes as interfaces

In Dart, the idiomatic way to define a pure interface (a contract with no implementation) is to use an abstract class with only abstract methods. This clearly communicates intent: “this class exists only to be implemented, never to be extended for shared behaviour.”

// Idiomatic Dart — abstract class as interface
abstract class Serializable {
  Map<String, dynamic> toJson();
}

abstract class Searchable {
  bool matches(String query);
  List<String> getSearchTokens();
}

abstract class Cacheable {
  String get cacheKey;
  Duration get cacheDuration;
}

4. The implements keyword

The implements keyword places a class under a contract. A class can implement multiple interfaces, separated by commas — with no limit.

// document.dart

abstract class Serializable {
  Map<String, dynamic> toJson();
}

abstract class Searchable {
  bool matches(String query);
  List<String> getSearchTokens();
}

abstract class Printable {
  String toPrintFormat();
}

// Document satisfies THREE contracts simultaneously
class Document implements Serializable, Searchable, Printable {
  final String title;
  final String body;

  Document(this.title, this.body);

  // Must implement ALL methods from ALL interfaces
  @override
  Map<String, dynamic> toJson() => {'title': title, 'body': body};

  @override
  bool matches(String query) =>
      title.toLowerCase().contains(query.toLowerCase()) ||
      body.toLowerCase().contains(query.toLowerCase());

  @override
  List<String> getSearchTokens() => [
    ...title.split(' '),
    ...body.split(' '),
  ];

  @override
  String toPrintFormat() => '=== $title ===\n$body';
}

void main() {
  final doc = Document('Dart OOP Guide', 'Interfaces are powerful...');

  // Document can be used as any of its interface types
  Serializable s = doc;
  Searchable   q = doc;
  Printable    p = doc;

  print(s.toJson());
  print(q.matches('interfaces')); // true
  print(p.toPrintFormat());
}

This is the power of implements: a single object can be passed to a function expecting Serializableand another expecting Searchableand another expecting Printable — all at the same time. It satisfies all three contracts simultaneously.


5. extends vs. implements — the key distinction

extends

class Dog extends Animal {
  // Gets Animal's fields for free
  // Gets Animal's methods for free
  // Can call super.method()
  // Can only extend ONE class
  // IS-A relationship: Dog is an Animal

  // Only needs to add/override
  // what's different
  void bark() => print('Woof!');
}

implements

class Robot implements Animal {
  // Gets NOTHING from Animal
  // Must rewrite every single member
  // Cannot call super
  // Can implement MANY interfaces
  // CAN-DO relationship: Robot can act like Animal

  // Must implement ALL of Animal's
  // public members from scratch
  @override
  String name = 'RoboDog';
  @override
  void breathe() => print('*whirr*');
}

The mental model is simple: extends is an is-a relationship that gives you free implementation. implements is a can-do or behaves-like relationship that gives you only a contract. A Robot is not an Animal, but it can be made to behave like one by implementing the same interface.

The rule of thumb

Ask: “do I want to inherit implementation from this class?” If yes, use extends. If you only want to declare a type relationship or satisfy a contract — and you’ll write your own implementation — use implements.


6. Mixins — reusable behaviour without inheritance

Here is the problem that implements alone cannot solve: if ten message types all need to be Serializable, and the serialisation logic is identical for all of them, every class still has to write its own implementation. That’s duplication — and duplication is exactly what we’ve been working to eliminate.

Enter mixins. A mixin is a bundle of methods and fields designed to be mixed into a class using the with keyword. Unlike inheritance, a mixin does not create a parent-child relationship. And unlike implements, a mixin does provide implementation — it contributes real, reusable code to any class it’s mixed into.

// loggable.dart

// Declare a mixin with the 'mixin' keyword
mixin Loggable {
  final List<String> _logs = [];

  void log(String message) {
    final entry = '[${DateTime.now().toIso8601String()}] $message';
    _logs.add(entry);
  }

  List<String> get logs => List.unmodifiable(_logs);

  void clearLogs() => _logs.clear();
}

// Mix into ANY class — no shared parent needed
class TextMessage extends Message with Loggable {
  final String text;
  // ... constructor and overrides ...
}

class User with Loggable {
  final String username;
  User(this.username);
}

class ApiService with Loggable {
  void fetchMessages() {
    log('Fetching messages from server...');
    // ... http call ...
    log('Messages fetched successfully.');
  }
}

void main() {
  final user = User('alice');
  user.log('User logged in.');
  user.log('User sent a message.');
  print(user.logs); // two log entries

  final api = ApiService();
  api.fetchMessages();
  print(api.logs); // two log entries
}

User and ApiService have no common parent — they share no inheritance chain. But they both have full logging capability because they both mix in Loggable. This is horizontal code reuse: sharing behaviour across unrelated classes, which inheritance alone cannot do.

The key insight

Inheritance shares behaviour vertically — down a parent-child chain. Mixins share behaviour horizontally — across completely unrelated classes. These two mechanisms complement each other, and together they eliminate almost all need for code duplication.

Mixins can have state

Unlike interfaces, mixins can carry fields as well as methods. This means a mixin can maintain its own internal state, which gets added to every class it’s mixed into:

mixin Cacheable {
  // These fields are added to every class that uses this mixin
  DateTime? _cachedAt;
  static final Duration _defaultTtl = const Duration(minutes: 5);

  DateTime? get cachedAt => _cachedAt;

  bool get isCacheValid {
    if (_cachedAt == null) return false;
    return DateTime.now().difference(_cachedAt!) < _defaultTtl;
  }

  void markCached() => _cachedAt = DateTime.now();

  void invalidateCache() => _cachedAt = null;
}

Multiple mixins on one class

You can apply several mixins to a single class. They are listed after with, separated by commas:

// extends gives us the parent chain
// with gives us multiple horizontal capabilities
// implements declares additional type contracts
class TextMessage extends Message
    with Loggable, Cacheable
    implements Searchable {

  final String text;

  // ... constructor ...

  // From Loggable: log(), logs, clearLogs() — FREE
  // From Cacheable: markCached(), isCacheValid — FREE
  // From Searchable: must implement ourselves (it's an interface)
  @override
  bool matches(String query) =>
      text.toLowerCase().contains(query.toLowerCase());

  @override
  List<String> getSearchTokens() => text.split(' ');
}

7. The on clause — restricting mixin usage

Sometimes a mixin only makes sense when applied to a specific class or its subclasses. The on keyword restricts which classes a mixin can be mixed into — and in doing so, grants the mixin access to the host class’s members.

dart

abstract class Message {
  String get id;
  String get senderName;
  DateTime get timestamp;
  void markAsRead();
}

// This mixin can ONLY be used on Message (or its subclasses)
mixin MessageAnalytics on Message {
  int _viewCount = 0;

  // Can access Message's members because of the 'on' clause
  void recordView() {
    _viewCount++;
    // 'id' and 'senderName' come from Message — guaranteed by 'on'
    print('Message $id from $senderName: view #$_viewCount');
    if (_viewCount == 1) markAsRead(); // also from Message
  }

  int get viewCount => _viewCount;
}

// ✅ TextMessage extends Message, so can use MessageAnalytics
class TextMessage extends Message with MessageAnalytics {
  // ...
}

// ❌ User does NOT extend Message — compile error!
// class User with MessageAnalytics { ... }

When to use on

Use the on clause when your mixin needs to call methods or access fields from a specific base class. Without on, the mixin has no knowledge of its host class. With on, it gains full access — but can only be applied to classes in that hierarchy.


8. Mixin linearisation order

When multiple mixins are applied to a class, they are processed in order from left to right, each one layered on top of the previous. If two mixins define the same method, the rightmost one wins. This ordering is called mixin linearisation.

dart

mixin A {
  String greet() => 'Hello from A';
}

mixin B {
  String greet() => 'Hello from B';
}

mixin C {
  String greet() => 'Hello from C';
}

class MyClass with A, B, C {}

void main() {
  print(MyClass().greet()); // Hello from C  ← rightmost wins
}

The effective class hierarchy after linearisation is: Object → A → B → C → MyClass. When greet() is called, Dart walks this chain from the right (most specific) end and finds C‘s implementation first.

Order matters

The order of mixins in a with clause is significant. If you have with Logger, DetailedLoggerDetailedLogger‘s methods take precedence over Logger‘s. Always list mixins from most general to most specific.

9. Composition — when to use a field instead

Inheritance and mixins are not always the right tool. Sometimes the simplest and most flexible approach is to give a class a field that holds another object, and delegate to it. This is called composition, and it follows the principle “favour composition over inheritance”.

// chat_room.dart

// ❌ Wrong: ChatRoom does NOT "extend" a list — it HAS one
// class ChatRoom extends List<Message> { ... } // never do this

// ✅ Correct: ChatRoom HAS-A list of messages (composition)
class ChatRoom {
  final String id;
  final String name;
  final List<User> _members = [];
  final List<Message> _messages = [];

  ChatRoom({required this.id, required this.name});

  // Delegates to the internal list — but with controlled access
  void addMessage(Message message) => _messages.add(message);

  void addMember(User user) {
    if (!_members.contains(user)) _members.add(user);
  }

  List<Message> get messages => List.unmodifiable(_messages);
  List<User>    get members  => List.unmodifiable(_members);
  int           get messageCount => _messages.length;

  List<Message> getUnread() =>
      _messages.where((m) => !m.isRead).toList();

  List<Message> search(String query) =>
      _messages
          .whereType<Searchable>()
          .where((m) => m.matches(query))
          .cast<Message>()
          .toList();
}

ChatRoom has messages; it is not a kind of message. ChatRoom has members; it is not a kind of user. Composition captures this naturally — and crucially, it keeps ChatRoom in full control of how its internal collections are accessed and mutated.

10. Decision guide: extendsimplementswith, or field?

extends

Use when

Your class is a specialised kind of the parent. You want to inherit and possibly override its implementation. One parent only.

implements

Use when

You need to declare a type contract without inheriting code. Or you want a class to satisfy multiple type requirements simultaneously.

with (mixin)

Use when

You want to share a reusable bundle of code across classes that don’t share a parent. The mixin provides real implementation.

KeywordInherits implementation?Multiple allowed?Creates parent-child?
extends✅ Yes — fields and methods❌ One only✅ Yes — IS-A
implements❌ No — contract only✅ UnlimitedCAN-DO only
with (mixin)✅ Yes — full methods + fields✅ Multiple❌ No hierarchy
field (composition)Delegated as needed✅ Unlimited❌ HAS-A only

11. Building our chat app: Serializable, Cacheable & Loggable

Let’s now add three real capabilities to our chat app using the techniques from this article. Each one will be implemented as a mixin so it can be shared across message types, users, and services without duplication.

The Serializable mixin

// serializable.dart

// Abstract interface — defines the contract
abstract class JsonSerializable {
  Map<String, dynamic> toJson();
  void validate();
}

// Mixin — provides the shared toJson base structure for messages
mixin MessageSerializable on Message implements JsonSerializable {
  // Provides common fields — subclasses call super.toJson() and extend it
  @override
  Map<String, dynamic> toJson() => {
    'id':          id,
    'sender_name': senderName,
    'timestamp':   timestamp.toIso8601String(),
    'is_read':     isRead,
    'content_type': contentType(),
  };

  @override
  void validate() {
    if (id.isEmpty) throw ArgumentError('Message id cannot be empty');
    if (senderName.isEmpty) throw ArgumentError('Sender name cannot be empty');
  }
}

The Cacheable mixin

// cacheable.dart

mixin Cacheable {
  DateTime? _cachedAt;
  static const Duration _ttl = Duration(minutes: 10);

  DateTime? get cachedAt     => _cachedAt;
  bool      get isCacheValid {
    if (_cachedAt == null) return false;
    return DateTime.now().difference(_cachedAt!) < _ttl;
  }

  void markCached()      => _cachedAt = DateTime.now();
  void invalidateCache() => _cachedAt = null;

  /// Subclasses may override to customise TTL per type
  Duration get cacheDuration => _ttl;
}

The Loggable mixin

// loggable.dart

mixin Loggable {
  final List<String> _logs = [];

  void log(String event) {
    final ts = DateTime.now();
    final h  = ts.hour.toString().padLeft(2, '0');
    final m  = ts.minute.toString().padLeft(2, '0');
    final s  = ts.second.toString().padLeft(2, '0');
    _logs.add('[$h:$m:$s] $event');
  }

  List<String> get logs       => List.unmodifiable(_logs);
  int           get logCount   => _logs.length;
  void          clearLogs()   => _logs.clear();

  void printLogs() {
    if (_logs.isEmpty) { print('No logs.'); return; }
    _logs.forEach(print);
  }
}

The Searchable interface

// searchable.dart

// Pure interface — no implementation, just a contract
abstract class Searchable {
  /// Returns true if this object matches the query
  bool matches(String query);

  /// Returns all indexable tokens for search indexing
  List<String> getSearchTokens();
}

Assembling TextMessage with all capabilities

// text_message.dart — fully assembled

class TextMessage extends Message
    with MessageSerializable, Cacheable, Loggable
    implements Searchable {

  final String text;

  TextMessage({
    required String id,
    required String senderName,
    required DateTime timestamp,
    required this.text,
    bool isRead = false,
  }) : super(id: id, senderName: senderName,
             timestamp: timestamp, isRead: isRead);

  // ── Required by abstract Message ────────────────
  @override
  String preview() =>
      text.length > 40 ? '${text.substring(0, 40)}...' : text;

  @override
  String contentType() => 'text';

  // ── Extended toJson — adds text-specific fields ──
  @override
  Map<String, dynamic> toJson() => {
    ...super.toJson(),   // base fields from MessageSerializable
    'text': text,       // text-specific field
  };

  // ── Required by Searchable interface ─────────────
  @override
  bool matches(String query) {
    final q = query.toLowerCase();
    return text.toLowerCase().contains(q) ||
           senderName.toLowerCase().contains(q);
  }

  @override
  List<String> getSearchTokens() => [
    ...text.split(' '),
    senderName,
  ];
}

Using it all together

// main.dart

void main() {
  final msg = TextMessage(
    id: 'msg_001',
    senderName: 'Alice',
    timestamp: DateTime.now(),
    text: 'Hey! Are you coming to the meeting tonight?',
  );

  // ── From abstract Message ─────────────────────────
  print(msg);               // [HH:MM] Alice: Hey! Are you coming to the meeti...
  print(msg.contentType()); // text

  // ── From MessageSerializable mixin ────────────────
  msg.validate();           // no throw — valid message
  print(msg.toJson());      // {id: msg_001, sender_name: Alice, text: Hey!...}

  // ── From Cacheable mixin ──────────────────────────
  print(msg.isCacheValid);  // false — not cached yet
  msg.markCached();
  print(msg.isCacheValid);  // true
  print(msg.cachedAt);      // DateTime

  // ── From Loggable mixin ───────────────────────────
  msg.log('Message displayed in chat list.');
  msg.log('Message tapped — opening detail view.');
  msg.markAsRead();
  msg.log('Message marked as read.');
  msg.printLogs();
  // [HH:MM:SS] Message displayed in chat list.
  // [HH:MM:SS] Message tapped — opening detail view.
  // [HH:MM:SS] Message marked as read.

  // ── From Searchable interface ─────────────────────
  print(msg.matches('meeting')); // true
  print(msg.matches('lunch'));   // false

  // ── Polymorphic usage ─────────────────────────────
  // The same object can be passed wherever any of its types are expected
  processMessage(msg);     // accepts Message
  cacheObject(msg);        // accepts Cacheable
  searchObject(msg, 'meeting'); // accepts Searchable

  // ── Loggable applied to a completely unrelated class ─
  print('\n── User also has Loggable ──');
  final user = User(id: 'usr_001', username: 'alice',
      displayName: 'Alice', password: 'pass');
  user.log('User logged in.');
  user.log('User sent a message.');
  user.printLogs();
}

void processMessage(Message m) =>
    print('Processing: ${m.preview()}');

void cacheObject(Cacheable c) =>
    print('Cache valid: ${c.isCacheValid}');

void searchObject(Searchable s, String q) =>
    print('Matches "$q": ${s.matches(q)}');

12. The full class diagram

13. Exercises

Exercise 1 — Add Cacheable to User

Mix Cacheable into the User class. Override cacheDuration to return a longer TTL (say, 30 minutes) since user profiles change less frequently than messages. Write a main() that creates a User, marks them cached, checks validity, then calls invalidateCache() and checks validity again. Also write a helper function void refreshIfNeeded(Cacheable c) that prints “Cache valid, skipping” or “Refreshing cache…” depending on isCacheValid — and call it with both a User and a TextMessage.

Exercise 2 — Searchable ChatRoom

Make ChatRoom implement Searchable. Its matches(query) should return true if the room name matches. Its getSearchTokens() should return all tokens from the room name plus all tokens from all messages that are themselves Searchable. Write a function List<Searchable> findAll(List<Searchable> items, String query) that returns all matching items — and verify that a list containing both ChatRoom and TextMessage objects can be searched polymorphically.

Exercise 3 — Compose a full message pipeline

Build a MessagePipeline class using composition (not inheritance). It should hold a List<Message> internally and expose three operations: add(Message m) — validates and caches the message if it implements JsonSerializable and Cacheablesearch(String query) — returns all messages that implement Searchable and match the query; and exportAll() — returns a List<Map> of all messages that implement JsonSerializable. This exercise demonstrates how composition, interfaces, and mixins work together in a real service.

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *

Lên đầu trang