What is it? #
Abstraction is describing what something does without committing to how it does it.
It is related to encapsulation but not the same. Encapsulation hides internal data. Abstraction hides the entire implementation behind a concept — "store this file" rather than "write these bytes to this S3 bucket".
In code, an abstraction is usually a small set of method signatures: an interface. Anything providing those methods can be used.
The right level matters. Too little abstraction and your code is welded to one library. Too much and you have layers of indirection nobody can follow.
Think of it like this #
A light switch. The concept is "turn the light on". Behind it could be a filament bulb, an LED, or a smart relay talking to a server.
You do not need to know, and the electrician can change what is behind the wall without teaching you a new gesture. That is abstraction: a stable idea, a replaceable mechanism.
Simple example #
An application stores uploaded files. Today they go to local disk, tomorrow to cloud storage. If the application calls storage.save(...) rather than filesystem functions directly, that switch is a configuration change instead of a rewrite.
Code #
from abc import ABC, abstractmethod
from pathlib import Path
class FileStorage(ABC):
"""What the application needs from storage. Nothing about how."""
@abstractmethod
def save(self, key: str, data: bytes) -> str: ...
@abstractmethod
def load(self, key: str) -> bytes: ...
@abstractmethod
def delete(self, key: str) -> None: ...
class LocalStorage(FileStorage):
def __init__(self, root: str):
self.root = Path(root)
self.root.mkdir(parents=True, exist_ok=True)
def save(self, key, data):
path = self.root / key
path.write_bytes(data)
return str(path)
def load(self, key):
return (self.root / key).read_bytes()
def delete(self, key):
(self.root / key).unlink(missing_ok=True)
class InMemoryStorage(FileStorage):
"""Used in tests — no disk, no network, instant."""
def __init__(self):
self._files: dict[str, bytes] = {}
def save(self, key, data):
self._files[key] = data
return f"memory://{key}"
def load(self, key):
return self._files[key]
def delete(self, key):
self._files.pop(key, None)
# Application code depends on the abstraction, not on any implementation
class AvatarService:
def __init__(self, storage: FileStorage):
self.storage = storage
def upload(self, user_id: int, image: bytes) -> str:
if len(image) > 2_000_000:
raise ValueError("image too large")
return self.storage.save(f"avatars/{user_id}.png", image)
service = AvatarService(InMemoryStorage()) # in tests
print(service.upload(7, b"fake-image-bytes")) # memory://avatars/7.png
# In production: AvatarService(LocalStorage("/var/app/uploads"))
# Later, without touching AvatarService: AvatarService(S3Storage(bucket="avatars"))
How it works #
FileStorage defines three methods and nothing else. It says what the application needs: save something under a key, load it back, delete it. There is no mention of paths, buckets or connections.
LocalStorage and InMemoryStorage implement the same three methods completely differently. Both are valid storage as far as the rest of the application is concerned.
AvatarService takes a FileStorage in its constructor. It never creates one itself, which means it never depends on a specific implementation. This is dependency inversion, covered in the SOLID track.
The size limit check lives in the service, because it is a business rule rather than a storage concern. Deciding which layer owns which rule is most of the work in designing abstractions.
Swapping implementations is now a one-line change at the point where the service is created. Adding cloud storage means writing a new class, not editing AvatarService.
The test benefit is immediate: InMemoryStorage makes tests fast and self-contained, with no temporary directories to clean up and no credentials to configure.
Real-world use #
Every serious codebase has abstractions at its boundaries: storage, payments, email, search, caching, message queues. Those are exactly the places where vendors change and where tests need substitutes.
The abstraction should be shaped by what your application needs, not by what a particular library offers. An interface that mirrors one vendor's API one-to-one gives you no freedom — swapping vendors would change the interface.
Internal code usually needs less abstraction than people add. Wrapping your own database models in three layers of interfaces, when you have no intention of changing databases, costs clarity and buys nothing.
A reasonable rule: abstract across boundaries you might cross, and keep internal code direct.
Common mistakes #
- Designing the interface around one library’s API, which prevents ever swapping it.
- Adding abstraction layers for things that will never change.
- Leaking implementation details into the interface, such as an S3-specific error type.
- Making the interface huge, so every implementation must provide methods it does not need.
- Confusing abstraction with encapsulation — one hides implementation, the other hides data.
Practice #
Define a NotificationSender interface with one method. Write two implementations: one that prints (for development) and one that appends to a list (for tests). Then write a WelcomeFlow class that takes a sender and uses it, and prove the flow can be tested without sending anything.