Abstract Classes and Interfaces
Introduction: Defining Contracts, Not Implementation
Imagine you're building a todo app that needs to work with multiple storage backends: save tasks to a JSON file, a database, or a cloud service. You COULD write separate code for each backend, but that creates coupling—your app logic becomes tangled with storage specifics.
Abstract classes solve this by defining what storage MUST do without specifying HOW it does it. This creates a contract—"Any storage backend must implement save(), load(), and delete()"—and then any implementation that satisfies this contract can be plugged in.
In this lesson, you'll learn how abstract classes enforce contracts, enable polymorphism, and create flexible architectures where components can be swapped at runtime. We'll use TaskStorage (the core abstraction from the Part 5 todo integration project) as our primary example—a real-world pattern you'll use to build professional systems.
Understanding Abstract Classes: Contracts as Code
An abstract class is a class you can't instantiate directly. Instead, it defines a set of methods that subclasses MUST implement. Think of it as a blueprint: "Here's what an interface must do. You fill in the implementation details."
Why Abstract Classes Matter
Consider this problem: Your app needs to save tasks. The app doesn't care whether tasks go to a file, database, or cloud service—it just needs storage that works. An abstract class encodes this requirement:
from abc import ABC, abstractmethod
class AbstractTaskStorage(ABC):
"""Abstract contract: any task storage must implement these methods."""
@abstractmethod
def save(self, tasks: list) -> None:
"""Save all tasks to storage. Subclasses must implement."""
pass
@abstractmethod
def load(self) -> list:
"""Load all tasks from storage. Subclasses must implement."""
pass
@abstractmethod
def delete(self, task_id: int) -> bool:
"""Delete a task by ID. Subclasses must implement."""
pass
Key insight: You can't do storage = AbstractTaskStorage(). Python will raise TypeError: Can't instantiate abstract class. You MUST create a concrete subclass that implements all abstract methods.
The Contract Guarantee
Once you define abstract methods, Python guarantees:
- Subclasses must implement all abstract methods or Python raises TypeError
- The interface is consistent across all implementations
- Client code can depend on the interface, not implementation details
💬 AI Colearning Prompt
"Show me what happens when I create a subclass of AbstractTaskStorage but only implement
save()without implementingload()anddelete(). What error does Python raise? Why does Python enforce this?"
Expected Understanding: AI will demonstrate that Python prevents incomplete implementations. This is the core value—you can't accidentally forget to implement part of the interface.
Concrete Implementations: Making Abstract Contracts Real
Now that you have an abstract interface, create concrete implementations:
FileTaskStorage: Storing Tasks in JSON
import json
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class Task:
"""A single todo item."""
id: int
title: str
description: str = ""
done: bool = False
priority: int = 5
class FileTaskStorage(AbstractTaskStorage):
"""Store tasks in a JSON file."""
def __init__(self, filename: str = "tasks.json"):
self.filename = filename
def save(self, tasks: list[Task]) -> None:
"""Save all tasks to JSON file."""
task_dicts = [
{
"id": task.id,
"title": task.title,
"description": task.description,
"done": task.done,
"priority": task.priority
}
for task in tasks
]
with open(self.filename, 'w') as f:
json.dump(task_dicts, f, indent=2)
print(f"Saved {len(tasks)} tasks to {self.filename}")
def load(self) -> list[Task]:
"""Load all tasks from JSON file."""
try:
with open(self.filename) as f:
data = json.load(f)
tasks = [Task(**item) for item in data]
print(f"Loaded {len(tasks)} tasks from {self.filename}")
return tasks
except FileNotFoundError:
print(f"File {self.filename} not found, returning empty list")
return []
def delete(self, task_id: int) -> bool:
"""Delete a task by ID."""
tasks = self.load()
original_count = len(tasks)
tasks = [t for t in tasks if t.id != task_id]
if len(tasks) < original_count:
self.save(tasks)
print(f"Deleted task {task_id}")
return True
print(f"Task {task_id} not found")
return False
# Test it
storage = FileTaskStorage("my_tasks.json")
tasks = [
Task(id=1, title="Learn Python", priority=1),
Task(id=2, title="Build an app", priority=2),
]
storage.save(tasks)
loaded_tasks = storage.load()
print(f"Tasks: {loaded_tasks}")
storage.delete(1)
remaining = storage.load()
print(f"After delete: {remaining}")
Output:
Saved 2 tasks to my_tasks.json
Loaded 2 tasks from my_tasks.json
Tasks: [Task(id=1, title='Learn Python', description='', done=False, priority=1), Task(id=2, title='Build an app', description='', done=False, priority=2)]
Deleted task 1
Loaded 1 tasks from my_tasks.json
After delete: [Task(id=2, title='Build an app', description='', done=False, priority=2)]
DatabaseTaskStorage: Preview of Part 6
Here's a preview of how the same interface works with a database (you'll implement this in Part 6):
class DatabaseTaskStorage(AbstractTaskStorage):
"""Store tasks in a relational database (PostgreSQL, SQLite, etc.)."""
def __init__(self, connection_string: str):
self.connection_string = connection_string
# In Part 6: Create connection pool, initialize tables
def save(self, tasks: list[Task]) -> None:
"""Save tasks to database."""
# In Part 6: INSERT or UPDATE tasks in database table
for task in tasks:
# INSERT INTO tasks (id, title, description, done, priority) VALUES (...)
pass
print(f"Saved {len(tasks)} tasks to database")
def load(self) -> list[Task]:
"""Load tasks from database."""
# In Part 6: SELECT * FROM tasks
# Convert database rows to Task objects
pass
def delete(self, task_id: int) -> bool:
"""Delete task from database."""
# In Part 6: DELETE FROM tasks WHERE id = task_id
pass
Key insight: Both FileTaskStorage and DatabaseTaskStorage implement the SAME interface but with completely different implementations. Client code doesn't care which one is used.
🎓 Expert Insight
This abstraction is why professional systems are flexible. Netflix can swap storage backends. Spotify can change how user data is cached. Large systems depend on abstract interfaces, not concrete implementations. You're learning the pattern used in production systems.
Pluggable Architecture: Swap Implementations at Runtime
The true power of abstract classes emerges when you build systems that accept ANY implementation:
TodoApp: Works with Any TaskStorage
class TodoApp:
"""A todo application that works with ANY storage backend."""
def __init__(self, storage: AbstractTaskStorage):
"""Accept ANY storage implementation (file, database, cloud, etc.)"""
self.storage = storage
self.tasks = self.storage.load()
self.next_id = max([t.id for t in self.tasks], default=0) + 1
def add_task(self, title: str, description: str = "", priority: int = 5) -> Task:
"""Add a new task."""
task = Task(
id=self.next_id,
title=title,
description=description,
done=False,
priority=priority
)
self.tasks.append(task)
self.next_id += 1
self.storage.save(self.tasks)
return task
def mark_complete(self, task_id: int) -> bool:
"""Mark a task as complete."""
for task in self.tasks:
if task.id == task_id:
task.done = True
self.storage.save(self.tasks)
return True
return False
def list_pending(self) -> list[Task]:
"""List all pending tasks."""
return [t for t in self.tasks if not t.done]
def delete_task(self, task_id: int) -> bool:
"""Delete a task."""
return self.storage.delete(task_id)
# Use with FILE storage
file_storage = FileTaskStorage("my_tasks.json")
app_with_files = TodoApp(file_storage)
app_with_files.add_task("Learn Python", priority=1)
app_with_files.add_task("Build an app", priority=2)
print("File-based app:", app_with_files.list_pending())
# Use with DATABASE storage (in Part 6, this will work exactly the same way)
# db_storage = DatabaseTaskStorage("postgres://localhost/tododb")
# app_with_db = TodoApp(db_storage)
# app_with_db.add_task("Learn Python", priority=1)
# app_with_db.add_task("Build an app", priority=2)
# print("Database app:", app_with_db.list_pending())
# Output: IDENTICAL! Same code, different backend.
Output:
Loaded 0 tasks from my_tasks.json
Saved 2 tasks to my_tasks.json
File-based app: [Task(id=1, title='Learn Python', description='', done=False, priority=1), Task(id=2, title='Build an app', description='', done=False, priority=2)]
Critical insight: The TodoApp code doesn't know or care whether tasks are stored in a file or database. It depends ONLY on the AbstractTaskStorage interface. This is polymorphism in action—code written once, works with multiple implementations.
🚀 CoLearning Challenge
Ask your AI Co-Teacher:
"I have FileTaskStorage and DatabaseTaskStorage, both implementing AbstractTaskStorage. Show me how a function
export_to_backup(app: TodoApp)can export tasks from ANY storage backend without knowing which concrete storage type is being used. Why does this work? What breaks if we don't use abstract classes?"
Expected Outcome: AI will explain that polymorphism through abstract classes lets you write generic code that works with any implementation. Without it, you'd need separate functions for each storage type.
Applying the Pattern: Other Real-World Examples
Abstract classes aren't specific to task storage. This pattern applies everywhere:
NotificationService: Multiple Notification Channels
class AbstractNotifier(ABC):
"""Abstract interface for sending notifications."""
@abstractmethod
def notify(self, user_id: int, message: str) -> bool:
"""Send notification to a user. Return True if successful."""
pass
class EmailNotifier(AbstractNotifier):
"""Send notifications via email."""
def notify(self, user_id: int, message: str) -> bool:
print(f"Sending email to user {user_id}: {message}")
# In production: use SMTP to send actual email
return True
class SlackNotifier(AbstractNotifier):
"""Send notifications via Slack."""
def notify(self, user_id: int, message: str) -> bool:
print(f"Sending Slack message to user {user_id}: {message}")
# In production: use Slack API
return True
class SMSNotifier(AbstractNotifier):
"""Send notifications via SMS."""
def notify(self, user_id: int, message: str) -> bool:
print(f"Sending SMS to user {user_id}: {message}")
# In production: use Twilio or similar
return True
# Client code works with ANY notifier
def alert_user(notifier: AbstractNotifier, user_id: int, message: str):
"""Alert a user using any notification method."""
if notifier.notify(user_id, message):
print(f"Alert sent successfully")
else:
print(f"Alert failed")
# Try different implementations
email = EmailNotifier()
slack = SlackNotifier()
sms = SMSNotifier()
alert_user(email, 123, "Your task is due!")
alert_user(slack, 123, "Your task is due!")
alert_user(sms, 123, "Your task is due!")
Output:
Sending email to user 123: Your task is due!
Alert sent successfully
Sending Slack message to user 123: Your task is due!
Alert sent successfully
Sending SMS to user 123: Your task is due!
Alert sent successfully
CaseRepository: Multiple Data Sources
class AbstractCaseRepository(ABC):
"""Abstract interface for accessing legal cases."""
@abstractmethod
def find_case(self, case_id: str) -> dict:
"""Find a case by ID."""
pass
@abstractmethod
def list_cases(self, filter_criteria: dict) -> list[dict]:
"""List cases matching criteria."""
pass
class FileCaseRepository(AbstractCaseRepository):
"""Load cases from JSON files."""
def find_case(self, case_id: str) -> dict:
with open(f"cases/{case_id}.json") as f:
return json.load(f)
def list_cases(self, filter_criteria: dict) -> list[dict]:
cases = []
# Walk through files, filter by criteria
return cases
class APIBasedCaseRepository(AbstractCaseRepository):
"""Fetch cases from an external API (e.g., LegalInsight API)."""
def find_case(self, case_id: str) -> dict:
response = requests.get(f"https://api.legal.com/cases/{case_id}")
return response.json()
def list_cases(self, filter_criteria: dict) -> list[dict]:
response = requests.get("https://api.legal.com/cases", params=filter_criteria)
return response.json()
The pattern: Everywhere you need multiple implementations of similar behavior, abstract classes let you define the interface and swap implementations without changing client code.
Template Method Pattern: Structuring Algorithms
Abstract classes can define partial implementations (algorithms with steps delegated to subclasses):
AbstractDataProcessor: Defining Algorithm Structure
class AbstractDataProcessor(ABC):
"""Template: Define the process structure, subclasses implement steps."""
def process(self, data: list) -> list:
"""Template method: defines the algorithm structure."""
# Algorithm steps are in order; subclasses implement each
validated_data = self.validate(data)
transformed_data = self.transform(validated_data)
result = self.save(transformed_data)
return result
@abstractmethod
def validate(self, data: list) -> list:
"""Subclasses must implement validation."""
pass
@abstractmethod
def transform(self, data: list) -> list:
"""Subclasses must implement transformation."""
pass
@abstractmethod
def save(self, data: list) -> list:
"""Subclasses must implement saving."""
pass
class TaskDataProcessor(AbstractDataProcessor):
"""Process task data: validate tasks, transform formats, save to storage."""
def validate(self, data: list) -> list:
"""Ensure all tasks have required fields."""
valid = []
for item in data:
if "title" in item and "id" in item:
valid.append(item)
return valid
def transform(self, data: list) -> list:
"""Convert to Task objects."""
return [Task(**item) for item in data]
def save(self, data: list) -> list:
"""Save to database."""
print(f"Saving {len(data)} tasks to database")
return data
class CSVDataProcessor(AbstractDataProcessor):
"""Process CSV data: validate format, transform to dicts, save to CSV."""
def validate(self, data: list) -> list:
"""Ensure all rows have required columns."""
return [row for row in data if len(row) >= 3]
def transform(self, data: list) -> list:
"""Convert rows to formatted dictionaries."""
return [{"col1": row[0], "col2": row[1], "col3": row[2]} for row in data]
def save(self, data: list) -> list:
"""Write to CSV file."""
print(f"Writing {len(data)} rows to CSV")
return data
# Use the pattern
task_processor = TaskDataProcessor()
csv_processor = CSVDataProcessor()
# Different algorithms, same interface
task_data = [
{"id": 1, "title": "Learn Python"},
{"id": 2, "title": "Build app"}
]
result1 = task_processor.process(task_data)
print(f"Task result: {result1}")
csv_data = [
["Alice", "Engineer", "2025-01-15"],
["Bob", "Designer", "2025-01-20"]
]
result2 = csv_processor.process(csv_data)
print(f"CSV result: {result2}")
Output:
Saving 2 tasks to database
Task result: [Task(id=1, title='Learn Python', description='', done=False, priority=5), Task(id=2, title='Build app', description='', done=False, priority=5)]
Writing 2 rows to CSV
CSV result: [{'col1': 'Alice', 'col2': 'Engineer', 'col3': '2025-01-15'}, {'col1': 'Bob', 'col2': 'Designer', 'col3': '2025-01-20'}]
Advanced Concept: Abstract Properties
You can require subclasses to implement properties:
class AbstractUser(ABC):
"""Abstract user with required properties."""
@property
@abstractmethod
def username(self) -> str:
"""Every user must have a username."""
pass
@property
@abstractmethod
def email(self) -> str:
"""Every user must have an email."""
pass
class DatabaseUser(AbstractUser):
"""User backed by database."""
def __init__(self, username: str, email: str):
self._username = username
self._email = email
@property
def username(self) -> str:
# Could fetch from database
return self._username
@property
def email(self) -> str:
return self._email
user = DatabaseUser("alice", "alice@example.com")
print(f"User: {user.username} ({user.email})")
Output:
User: alice (alice@example.com)
💬 AI Colearning Prompt
"Show me the difference between abstract properties (@property + @abstractmethod) and abstract methods. When would I use one vs the other? Can I override an abstract property with a regular method in a subclass?"
Design Patterns with Abstract Classes
Factory Pattern: Creating Objects
class AbstractStorageFactory(ABC):
"""Abstract factory: create storage instances."""
@abstractmethod
def create_storage(self) -> AbstractTaskStorage:
"""Create and return a storage implementation."""
pass
class FileStorageFactory(AbstractStorageFactory):
"""Create file-based storage."""
def create_storage(self) -> AbstractTaskStorage:
return FileTaskStorage("tasks.json")
class DatabaseStorageFactory(AbstractStorageFactory):
"""Create database-based storage."""
def __init__(self, connection_string: str):
self.connection_string = connection_string
def create_storage(self) -> AbstractTaskStorage:
return DatabaseTaskStorage(self.connection_string)
# Use factories
file_factory = FileStorageFactory()
db_factory = DatabaseStorageFactory("postgres://localhost/tododb")
file_storage = file_factory.create_storage()
db_storage = db_factory.create_storage()
# Both are AbstractTaskStorage; client code works with either
Common Pitfalls and Best Practices
Pitfall 1: Too Many Abstract Methods
Too many abstract methods makes concrete classes burdensome:
# BAD: Too detailed
class AbstractStorage(ABC):
@abstractmethod
def save(self): pass
@abstractmethod
def load(self): pass
@abstractmethod
def validate_format(self): pass
@abstractmethod
def encode_data(self): pass
@abstractmethod
def decode_data(self): pass
# ... 10 more methods
Better: Focus on core behavior; provide helper methods:
# GOOD: Core interface, helper methods
class AbstractStorage(ABC):
@abstractmethod
def save(self, data): pass
@abstractmethod
def load(self) -> list: pass
# Helper methods provided for subclasses to use
def _validate(self, data):
# Default validation logic
return True
def _encode(self, data):
# Default encoding
return json.dumps(data)
Pitfall 2: Abstracting Before You Understand Patterns
Don't create abstract classes for hypothetical future use cases. Only abstract when you have 2+ concrete implementations:
# BAD: Abstract before need
class AbstractAnimal(ABC):
@abstractmethod
def make_sound(self): pass
# Only used by Dog and Cat
# GOOD: Build concrete examples first, then abstract
class Dog:
def make_sound(self): return "Woof"
class Cat:
def make_sound(self): return "Meow"
# NOW see the pattern, create abstract:
class AbstractAnimal(ABC):
@abstractmethod
def make_sound(self): pass
# Refactor Dog and Cat to inherit
Practice: Design Your Own Pluggable System
Challenge 1: Create AbstractLogger Interface
Design a logging system with multiple backends:
# Your task:
# 1. Create AbstractLogger with @abstractmethod: log(level, message)
# 2. Create ConsoleLogger (prints to console)
# 3. Create FileLogger (writes to log file)
# 4. Create DatabaseLogger (saves to database)
# 5. Write code that works with ANY logger implementation
Ask your AI partner:
"Help me design AbstractLogger. What abstract methods should it have? Show me ConsoleLogger, FileLogger, and DatabaseLogger implementations. Write a function log_event() that accepts any logger and works the same for all."
Challenge 2: TaskStorage Variations
Extend the TaskStorage pattern:
# Your task:
# 1. Create MemoryTaskStorage (stores tasks in RAM—fast for testing)
# 2. Create EncryptedFileTaskStorage (encrypts tasks before saving)
# 3. Create CloudTaskStorage (upload to AWS S3 or similar)
# 4. Verify TodoApp works with ALL implementations without changes
🚀 CoLearning Challenge
"Create a CloudTaskStorage that stores tasks in AWS S3. Implement save() to upload JSON to S3, load() to download from S3, delete() to remove objects. Show me how TodoApp can use it exactly the same way as FileTaskStorage."
Try With AI
🔍 Explore Abstract Class Design:
"Design an AbstractPaymentProcessor with abstract methods: process(amount), refund(transaction_id), validate(card_data). Create CreditCardProcessor, PayPalProcessor, and CryptocurrencyProcessor that all implement the interface. Show me how client code can accept any processor without knowing which concrete type it is."
🎯 Practice Plugin Architecture:
"I have a game with multiple weapons: Sword, Bow, Wand. Create AbstractWeapon with abstract method: attack(target). Create concrete weapon classes. Show me how GameCharacter can accept any weapon and call attack() without knowing the specific weapon type."
🧪 Implement Template Method:
"Design AbstractEmailTemplate with a template method: send_email(recipient, data). The template defines the structure: validate_recipient → format_message → send → log_delivery. Show me EmailWelcomeTemplate and EmailNotificationTemplate implementing the abstract class."
🚀 Build Real-World Pluggable System:
"Design AbstractCache with methods: get(key), set(key, value), delete(key). Create MemoryCache, RedisCache, and MemcachedCache. Build UserService that accepts any cache implementation. Show how UserService.get_user(id) works the same with all cache types."
Summary
Abstract classes are the foundation of flexible, professional systems:
- Define contracts: Abstract methods force subclasses to implement required behavior
- Enable polymorphism: Code written once works with multiple implementations
- Create pluggable architectures: Swap implementations without changing client code
- Prevent incomplete implementations: Python enforces that subclasses implement all abstract methods
- Pattern real-world systems: Storage backends, notification services, repositories—abstract classes are everywhere
The TaskStorage pattern you learned is used in production systems at scale. You now understand the architecture underlying major platforms.