Dart Object-Oriented Programming

Abstract Classes & Interfaces

45 min Lesson 5 of 8

What Are Abstract Classes?

An abstract class is a class that cannot be instantiated directly -- you cannot create an object from it using new. Instead, it serves as a blueprint that other classes must extend and complete. Abstract classes can contain both abstract methods (no implementation, just the signature) and concrete methods (with full implementation). Use the abstract keyword before class.

Think of an abstract class like an incomplete recipe: it tells you the steps (method signatures) but some steps say “you decide how to do this part” (abstract methods). Every subclass must fill in those blanks.

Your First Abstract Class

// Cannot create: Shape() -- it is abstract
abstract class Shape {
  String color;

  Shape(this.color);

  // Abstract method -- no body, just signature
  // Subclasses MUST implement this
  double get area;

  // Abstract method
  double get perimeter;

  // Concrete method -- has a body, inherited by subclasses
  void describe() {
    print('A $color shape: area=${area.toStringAsFixed(2)}, perimeter=${perimeter.toStringAsFixed(2)}');
  }
}

class Circle extends Shape {
  double radius;

  Circle(String color, this.radius) : super(color);

  @override
  double get area => 3.14159 * radius * radius;

  @override
  double get perimeter => 2 * 3.14159 * radius;
}

class Rectangle extends Shape {
  double width;
  double height;

  Rectangle(String color, this.width, this.height) : super(color);

  @override
  double get area => width * height;

  @override
  double get perimeter => 2 * (width + height);
}

void main() {
  // Shape s = Shape('red');  // ERROR! Cannot instantiate abstract class

  Circle c = Circle('blue', 5);
  Rectangle r = Rectangle('green', 10, 4);

  c.describe();  // A blue shape: area=78.54, perimeter=31.42
  r.describe();  // A green shape: area=40.00, perimeter=28.00

  // Can use the abstract type as a variable type
  List<Shape> shapes = [c, r];
  double totalArea = shapes.fold(0, (sum, s) => sum + s.area);
  print('Total area: ${totalArea.toStringAsFixed(2)}');  // 118.54
}
Key Rule: If a subclass extends an abstract class, it must implement all abstract methods. If it does not, the subclass itself must also be declared abstract. The compiler will give you an error if you forget to implement an abstract method.

Abstract Methods vs Concrete Methods

An abstract class can mix both types freely:

Mixing Abstract and Concrete

abstract class Animal {
  String name;

  Animal(this.name);

  // Abstract -- every animal sounds different
  void makeSound();

  // Abstract -- every animal moves differently
  void move();

  // Concrete -- all animals eat the same way (for our purposes)
  void eat(String food) {
    print('$name is eating $food.');
  }

  // Concrete with abstract dependency
  void introduce() {
    print('I am $name.');
    makeSound();  // Calls the subclass implementation!
    move();
  }
}

class Dog extends Animal {
  Dog(String name) : super(name);

  @override
  void makeSound() => print('$name: Woof! Woof!');

  @override
  void move() => print('$name runs on four legs.');
}

class Bird extends Animal {
  Bird(String name) : super(name);

  @override
  void makeSound() => print('$name: Tweet! Tweet!');

  @override
  void move() => print('$name flies through the air.');
}

void main() {
  Dog dog = Dog('Rex');
  Bird bird = Bird('Tweety');

  dog.introduce();
  // I am Rex.
  // Rex: Woof! Woof!
  // Rex runs on four legs.

  bird.introduce();
  // I am Tweety.
  // Tweety: Tweet! Tweet!
  // Tweety flies through the air.

  // Concrete method is inherited
  dog.eat('bones');   // Rex is eating bones.
  bird.eat('seeds');   // Tweety is eating seeds.
}
Powerful Pattern: Notice how introduce() is a concrete method that calls abstract methods makeSound() and move(). The parent defines the sequence of operations, while subclasses define the specific behavior. This is called the Template Method Pattern -- one of the most useful design patterns in OOP.

Interfaces in Dart

Unlike Java or C#, Dart does not have a separate interface keyword. Instead, every class is implicitly an interface. Any class can be used as an interface with the implements keyword. When you implement a class, you must override all of its members -- both properties and methods.

Using implements

// This class automatically serves as an interface
class Printable {
  void printFormatted() {
    print('Default printable output');
  }
}

class Savable {
  void save() {
    print('Default save');
  }

  void delete() {
    print('Default delete');
  }
}

// Implements BOTH -- must override ALL members from both
class Document implements Printable, Savable {
  String title;
  String content;

  Document(this.title, this.content);

  @override
  void printFormatted() {
    print('=== $title ===');
    print(content);
    print('===============');
  }

  @override
  void save() {
    print('Saving document "$title" to disk...');
  }

  @override
  void delete() {
    print('Deleting document "$title"...');
  }
}

void main() {
  Document doc = Document('My Report', 'This is the content.');
  doc.printFormatted();
  doc.save();

  // Can use interface types
  Printable p = doc;
  p.printFormatted();  // Works! Doc implements Printable

  Savable s = doc;
  s.save();  // Works! Doc implements Savable
}
extends vs implements:
extends -- Inherit implementation. You get all the parent’s code for free and only override what you want to change. Can only extend one class.
implements -- Promise to provide implementation. You get nothing for free and must override every member. Can implement multiple classes.
Use extends when you want to reuse code. Use implements when you want to guarantee a class follows a contract.

Abstract Classes as Interfaces

The most common pattern is to define abstract classes specifically to be used as interfaces. This gives you abstract methods (the contract) without any implementation that needs to be overridden.

Abstract Interface Pattern

// Define contracts with abstract classes
abstract class AuthService {
  Future<bool> login(String email, String password);
  Future<void> logout();
  bool get isLoggedIn;
  String? get currentUserEmail;
}

abstract class StorageService {
  Future<void> save(String key, String value);
  Future<String?> load(String key);
  Future<void> remove(String key);
}

// Firebase implementation
class FirebaseAuth implements AuthService {
  bool _loggedIn = false;
  String? _email;

  @override
  Future<bool> login(String email, String password) async {
    // Simulate Firebase login
    print('Firebase: Logging in $email...');
    _loggedIn = true;
    _email = email;
    return true;
  }

  @override
  Future<void> logout() async {
    print('Firebase: Logging out...');
    _loggedIn = false;
    _email = null;
  }

  @override
  bool get isLoggedIn => _loggedIn;

  @override
  String? get currentUserEmail => _email;
}

// Mock implementation for testing
class MockAuth implements AuthService {
  bool _loggedIn = false;

  @override
  Future<bool> login(String email, String password) async {
    _loggedIn = email == 'test@test.com' && password == 'password';
    return _loggedIn;
  }

  @override
  Future<void> logout() async => _loggedIn = false;

  @override
  bool get isLoggedIn => _loggedIn;

  @override
  String? get currentUserEmail => _loggedIn ? 'test@test.com' : null;
}

// Code depends on the INTERFACE, not the implementation
class LoginScreen {
  final AuthService auth;  // Works with ANY AuthService

  LoginScreen(this.auth);

  Future<void> handleLogin(String email, String password) async {
    bool success = await auth.login(email, password);
    if (success) {
      print('Welcome, ${auth.currentUserEmail}!');
    } else {
      print('Login failed.');
    }
  }
}

void main() async {
  // Production: use Firebase
  var screen = LoginScreen(FirebaseAuth());
  await screen.handleLogin('ahmed@test.com', 'secret123');

  // Testing: use Mock
  var testScreen = LoginScreen(MockAuth());
  await testScreen.handleLogin('test@test.com', 'password');
}
Why This Matters in Flutter: This pattern is used everywhere in professional Flutter apps. You define an interface (abstract class), create a real implementation and a mock implementation, then swap them easily for testing. Packages like get_it and provider make this even more powerful with dependency injection.

Combining extends and implements

A class can both extend a parent and implement interfaces at the same time:

extends + implements Together

abstract class Loggable {
  void log(String message);
}

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

class BaseModel {
  final String id;
  final DateTime createdAt;

  BaseModel({required this.id}) : createdAt = DateTime.now();

  @override
  String toString() => 'BaseModel($id)';
}

// Extends BaseModel AND implements two interfaces
class User extends BaseModel implements Loggable, Serializable {
  String name;
  String email;

  User({required super.id, required this.name, required this.email});

  @override
  void log(String message) {
    print('[User:$id] $message');
  }

  @override
  Map<String, dynamic> toJson() => {
    'id': id,
    'name': name,
    'email': email,
    'created_at': createdAt.toIso8601String(),
  };

  @override
  String toJsonString() => toJson().toString();

  @override
  String toString() => 'User($name, $email)';
}

void main() {
  var user = User(id: 'u001', name: 'Ahmed', email: 'ahmed@test.com');

  // From BaseModel (extends)
  print(user.id);         // u001
  print(user.createdAt);  // DateTime

  // From Loggable (implements)
  user.log('Profile updated');  // [User:u001] Profile updated

  // From Serializable (implements)
  print(user.toJson());
  // {id: u001, name: Ahmed, email: ahmed@test.com, created_at: ...}

  // Can be used as any of its types
  Loggable loggable = user;
  Serializable serializable = user;
  BaseModel base = user;
  loggable.log('Test');
}

The interface Keyword (Dart 3+)

Dart 3 introduced class modifiers for more control. The interface class modifier makes a class that can be implemented but not extended outside its library:

Dart 3 Class Modifiers

// interface class -- can implement, cannot extend outside this file
interface class Validator {
  bool validate(String input) {
    return input.isNotEmpty;
  }
}

// base class -- can extend, cannot implement outside this file
base class Repository {
  void save(dynamic data) {
    print('Saving $data');
  }
}

// final class -- cannot extend OR implement outside this file
final class Config {
  final String apiUrl;
  const Config(this.apiUrl);
}

// sealed class -- can only be extended in same file (great for exhaustive switch)
sealed class Result {}

class Success extends Result {
  final String data;
  Success(this.data);
}

class Failure extends Result {
  final String error;
  Failure(this.error);
}

class Loading extends Result {}

String handleResult(Result result) {
  // Dart knows all possible subtypes -- exhaustive!
  return switch (result) {
    Success(data: var d) => 'Got: $d',
    Failure(error: var e) => 'Error: $e',
    Loading() => 'Loading...',
  };
}

void main() {
  print(handleResult(Success('Hello')));  // Got: Hello
  print(handleResult(Failure('404')));     // Error: 404
  print(handleResult(Loading()));           // Loading...
}
Summary of Dart 3 Modifiers:
abstract -- Cannot be instantiated directly.
interface -- Can be implemented but not extended (outside its library).
base -- Can be extended but not implemented (outside its library).
final -- Cannot be extended or implemented (outside its library).
sealed -- Cannot be extended outside the same file. Enables exhaustive pattern matching in switch.
mixin -- Can be used as a mixin (covered in the next lesson).

Practical Example: Plugin Architecture

Building a Payment Plugin System

// Define the interface contract
abstract class PaymentGateway {
  String get name;
  bool get isAvailable;

  Future<PaymentResult> processPayment({
    required double amount,
    required String currency,
    required String description,
  });

  Future<bool> refund(String transactionId);
}

class PaymentResult {
  final bool success;
  final String transactionId;
  final String? errorMessage;

  PaymentResult.success(this.transactionId)
      : success = true,
        errorMessage = null;

  PaymentResult.failure(this.errorMessage)
      : success = false,
        transactionId = '';

  @override
  String toString() => success
      ? 'Payment OK (txn: $transactionId)'
      : 'Payment FAILED: $errorMessage';
}

// Stripe implementation
class StripeGateway implements PaymentGateway {
  @override
  String get name => 'Stripe';

  @override
  bool get isAvailable => true;

  @override
  Future<PaymentResult> processPayment({
    required double amount,
    required String currency,
    required String description,
  }) async {
    print('Stripe: Processing \$$amount $currency...');
    return PaymentResult.success('stripe_${DateTime.now().millisecondsSinceEpoch}');
  }

  @override
  Future<bool> refund(String transactionId) async {
    print('Stripe: Refunding $transactionId...');
    return true;
  }
}

// PayPal implementation
class PayPalGateway implements PaymentGateway {
  @override
  String get name => 'PayPal';

  @override
  bool get isAvailable => true;

  @override
  Future<PaymentResult> processPayment({
    required double amount,
    required String currency,
    required String description,
  }) async {
    print('PayPal: Processing \$$amount $currency...');
    return PaymentResult.success('paypal_${DateTime.now().millisecondsSinceEpoch}');
  }

  @override
  Future<bool> refund(String transactionId) async {
    print('PayPal: Refunding $transactionId...');
    return true;
  }
}

// Checkout service depends on the INTERFACE
class CheckoutService {
  final PaymentGateway gateway;

  CheckoutService(this.gateway);

  Future<void> checkout(double amount) async {
    if (!gateway.isAvailable) {
      print('${gateway.name} is not available.');
      return;
    }

    var result = await gateway.processPayment(
      amount: amount,
      currency: 'USD',
      description: 'Order payment',
    );

    print(result);
  }
}

void main() async {
  // Easy to swap payment providers!
  var stripeCheckout = CheckoutService(StripeGateway());
  await stripeCheckout.checkout(99.99);
  // Stripe: Processing $99.99 USD...
  // Payment OK (txn: stripe_1234567890)

  var paypalCheckout = CheckoutService(PayPalGateway());
  await paypalCheckout.checkout(49.99);
  // PayPal: Processing $49.99 USD...
  // Payment OK (txn: paypal_1234567890)
}

Practice Exercise

Open DartPad and build: (1) Create an abstract class DataRepository with abstract methods: Future<List<Map>> getAll(), Future<Map?> getById(String id), Future<void> create(Map data), Future<void> update(String id, Map data), Future<void> delete(String id), and a concrete method exists(String id) that calls getById. (2) Create InMemoryRepository implements DataRepository that stores data in a List<Map>. (3) Create LoggingRepository extends InMemoryRepository that overrides each method to print a log before calling super. (4) Use a DataRepository variable to work with both implementations. (5) Create a sealed class ApiResponse with Success, Error, and Loading subtypes, and handle them with an exhaustive switch expression.