OOP Series

Abstraction: Tell It Where to Go, Not How to Get There

Alex commutes to NYU every morning.

Monday, he hails a Yellow Cab. The driver asks: “Where to?” Alex says: “Washington Square Park.” The driver fires up the engine, reads the traffic, calculates the fare. Alex looks out the window.

Wednesday, the MTA bus. Which gear it’s in, how the brakes work, what the injectors are doing — he has no idea. He swipes his MetroCard, says “NYU,” and sits down.

Friday, Lyft. He pins the destination in the app. How the GPS optimizes the route, which traffic layer it’s reading, what the surge algorithm is doing — none of it is visible. He just knows: he’ll get there.

Three different vehicles. Three different engines, technologies, routing systems. But Alex’s experience is always the same: tell it where to go, get there.

That idea has a name: Abstraction.


1. What is Abstraction?

Alex uses three vehicles but stays completely free from their internal complexity. How many horsepower the cab has, what hydraulic pressure the bus brakes use, which satellite Lyft’s GPS locks onto — he doesn’t need to know any of it. He only talks to the interface: “Where are we going?”

In programming, Abstraction does exactly this: it hides complex internal implementation and only exposes what the caller actually needs.

Simple formula: Abstraction = Hide the complexity + Show a simple interface.

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#F5F5F5', 'primaryTextColor': '#000', 'primaryBorderColor': '#333', 'lineColor': '#333', 'background': '#fff'}}}%% graph LR classDef box fill:#F5F5F5,color:#000,stroke:#333 U["User"]:::box I["Public Interface travel(destination) get_fare()"]:::box C["Hidden Complexity engine mechanics GPS routing fare calculation"]:::box U -->|"only sees this"| I I -->|"what happens inside?"| C U -.->|"cannot access directly ✗"| C

The biggest benefit of Abstraction is separating “what” from “how.” Alex knows what will happen (he’ll reach his destination), but he doesn’t need to know how. That separation is what makes large software systems manageable.


2. Abstract Class: A Shared Blueprint

Think about Alex’s three vehicles. The Yellow Cab, the MTA bus, and Lyft all share certain behaviors — they all calculate a fare, they all give a trip summary. But how each one gets you there is completely different.

That’s exactly the situation Abstract Classes are built for.

An abstract class provides a shared blueprint: it implements the parts that work the same for everyone, and for the parts that differ, it says “you handle that yourself.”

from abc import ABC, abstractmethod

class Transport(ABC):
    def __init__(self, name: str):
        self.name = name

    @abstractmethod
    def travel(self, destination: str):
        # each vehicle travels in its own way
        pass

    @abstractmethod
    def get_fare(self, duration_min: int) -> float:
        # fare calculation differs per vehicle
        pass

    def trip_summary(self, destination: str, duration: int):
        # shared across all vehicles — written once
        fare = self.get_fare(duration)
        print(f"Vehicle: {self.name}")
        print(f"Destination: {destination}")
        print(f"Fare: ${fare:.2f}")

trip_summary() is the same for every vehicle, so it lives in the abstract class — written once. But travel() and get_fare() differ for each, so @abstractmethod marks them as “each subclass must define this itself.”

class YellowCab(Transport):
    def travel(self, destination: str):
        print(f"Cab weaving through Midtown traffic to {destination}...")

    def get_fare(self, duration_min: int) -> float:
        return 3.00 + (duration_min * 0.50)  # base + time

class MTABus(Transport):
    def travel(self, destination: str):
        print(f"Bus rolling along its fixed route to {destination}...")

    def get_fare(self, duration_min: int) -> float:
        return 2.90  # flat MetroCard fare

class Lyft(Transport):
    def travel(self, destination: str):
        print(f"Lyft driver following GPS route to {destination}...")

    def get_fare(self, duration_min: int) -> float:
        return 5.00 + (duration_min * 0.75)  # base + time

Now look at Alex’s commute in code:

def commute(vehicle: Transport, destination: str, duration: int):
    vehicle.travel(destination)
    vehicle.trip_summary(destination, duration)

# Monday
commute(YellowCab("NYC Cab 4782"), "Washington Square Park", 20)
# Cab weaving through Midtown traffic to Washington Square Park...
# Vehicle: NYC Cab 4782 | Destination: Washington Square Park | Fare: $13.00

# Wednesday
commute(MTABus("M15 Bus"), "Washington Square Park", 35)
# Bus rolling along its fixed route to Washington Square Park...
# Vehicle: M15 Bus | Destination: Washington Square Park | Fare: $2.90

# Friday
commute(Lyft("Lyft"), "Washington Square Park", 15)
# Lyft driver following GPS route to Washington Square Park...
# Vehicle: Lyft | Destination: Washington Square Park | Fare: $16.25

The commute() function doesn’t know any specific vehicle. It only knows the Transport interface. If an e-scooter service launches tomorrow, just add a new class. Not a single line of commute() changes.

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#F5F5F5', 'primaryTextColor': '#000', 'primaryBorderColor': '#333', 'lineColor': '#333', 'background': '#fff'}}}%% graph TD classDef box fill:#F5F5F5,color:#000,stroke:#333 A["Transport (Abstract Class) travel(): abstract get_fare(): abstract trip_summary(): shared for all"]:::box B["Vehicle A travel(): own route get_fare(): by meter"]:::box C["Vehicle B travel(): fixed route get_fare(): flat fare"]:::box D["Vehicle C travel(): GPS route get_fare(): base + time"]:::box A -->|"extends"| B A -->|"extends"| C A -->|"extends"| D

3. Abstraction vs. Encapsulation

We covered Encapsulation in the last article. The two sound similar, but they look at the problem from different angles.

Encapsulation says: hide the data, don’t let anyone touch it directly. It’s about internal protection — keeping __balance private, requiring a PIN to get in.

Abstraction says: hide the complexity, show a simple interface. It’s about the external view — Alex just calls travel("NYU"), he doesn’t need to know anything about the engine.

Using the car analogy: the accelerator pedal is Abstraction (press it, the rest isn’t your concern), and the sealed engine housing is Encapsulation (you can’t reach inside).

Aspect Encapsulation Abstraction
Goal Protect data Hide complexity
Perspective Inside out Outside in
Example Keeping __balance private Exposing only travel()
Question “Who can see this?” “Does the caller need to know this?”

They work together. Encapsulation protects. Abstraction simplifies.


Real-World Example: DoorDash Notification System

When DoorDash confirms an order, they need to notify the customer. Three channels: SMS, push notification, email. Each works completely differently internally — but the job is the same: deliver the message.

from abc import ABC, abstractmethod

class NotificationSender(ABC):
    def __init__(self, sender_name: str):
        self.sender_name = sender_name

    @abstractmethod
    def send(self, recipient: str, message: str) -> bool:
        pass

    def log_attempt(self, recipient: str):
        # shared logging — written once for all senders
        print(f"[{self.sender_name}] Notifying: {recipient}")

class SMSSender(NotificationSender):
    def send(self, recipient: str, message: str) -> bool:
        self.log_attempt(recipient)
        # SMS gateway API call... (complexity hidden)
        print(f"SMS sent to {recipient}: {message[:50]}...")
        return True

class PushSender(NotificationSender):
    def send(self, recipient: str, message: str) -> bool:
        self.log_attempt(recipient)
        # Firebase FCM API call... (complexity hidden)
        print(f"Push notification sent to device: {recipient}")
        return True

class EmailSender(NotificationSender):
    def send(self, recipient: str, message: str) -> bool:
        self.log_attempt(recipient)
        # SMTP handshake, email rendering... (complexity hidden)
        print(f"Email delivered to {recipient}")
        return True
# OrderService only knows NotificationSender — not the implementation
class OrderService:
    def __init__(self, notifier: NotificationSender):
        self.notifier = notifier

    def confirm_order(self, order_id: str, customer_contact: str):
        message = f"Order #{order_id} confirmed! On its way."
        self.notifier.send(customer_contact, message)
# notify via SMS
service = OrderService(SMSSender("Twilio SMS"))
service.confirm_order("DD-9921", "212-555-0199")

# switch to push notification — OrderService unchanged
service = OrderService(PushSender("Firebase"))
service.confirm_order("DD-9921", "device_token_xyz")

OrderService has no idea how the notification is being sent. It just calls send(). If DoorDash adds WhatsApp tomorrow, OrderService doesn’t change. log_attempt() is written once — every sender inherits it automatically.


Summary

In the story In code
Alex just says “Washington Square Park” Calling an abstract method
Cab, bus, Lyft each travel differently Concrete class implementations
Trip summary is the same for all vehicles Concrete method in the abstract class
Alex doesn’t know the engine, doesn’t need to Implementation hiding
New vehicle added, Alex’s experience unchanged Open for extension, closed for modification

Abstraction means separating “what” from “how.” The caller knows what will happen — not how.

An abstract class writes shared behavior once. Subclasses inherit it and implement only their own unique parts.

Encapsulation protects data. Abstraction hides complexity. They work side by side.


The next question: what if a class could inherit all the properties of another class — and add its own on top? That idea is called Inheritance.

Next in the OOP series: Inheritance — when the child gets the parent’s traits