Abstract Classes & Interfaces
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
}
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.
}
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 -- 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');
}
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...
}
•
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.