Object-Oriented Programming with Real Examples
Object-Oriented Programming (OOP) is more than a language feature set — it’s a way to model software so code mirrors real-world concepts, scales more cleanly, and becomes easier to understand, extend, and maintain. This article walks through OOP fundamentals, practical examples in several languages, design patterns, refactoring tips, testing, and trade-offs. If you’re a beginner, this gives clear entry points. If you’re experienced, expect refreshers and practical improvements you can apply right away.
What Is Object-Oriented Programming?
The basic idea
At its core, OOP organizes programs around objects — data structures that combine state (attributes/fields) and behavior (methods/functions). Instead of writing programs as a flat sequence of instructions, OOP represents entities and their interactions.
The four pillars
- Encapsulation — bundle data with the methods that act on it and hide internal details.
- Abstraction — expose essential features while hiding irrelevant complexity.
- Inheritance — create new classes based on existing ones to reuse and extend behavior.
- Polymorphism — handle different object types through a common interface.
Beyond the pillars
Useful additional ideas: composition, interfaces/contracts, and design principles (SOLID) to keep systems maintainable.
Why Use OOP? When It Helps
When OOP shines
- Modeling business domains (users, orders, invoices)
- GUI systems (windows, buttons, events)
- Games (players, enemies, items)
- Simulations (vehicles, sensors, environments)
Benefits
- Readability: domain concepts map to code constructs.
- Modularity: classes encapsulate responsibilities.
- Reusability: shared behavior via composition or inheritance.
- Extensibility: add new types with minimal changes.
Trade-offs
- Over-design with too many tiny classes can bloat a project.
- For simple scripts or data-transformation tasks, OOP may add unnecessary overhead.
- Performance: object creation and dynamic dispatch can be heavier than pure procedural or functional code (usually acceptable).
Encapsulation — Keep Complexity Inside
Why it matters
Encapsulation bundles data and methods and prevents external code from relying on internal details. It helps enforce invariants.
Example: BankAccount (Python)
class BankAccount:
def __init__(self, owner: str, initial: float = 0.0):
self._owner = owner
self._balance = float(initial)
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Deposit must be positive")
self._balance += amount
def withdraw(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Withdraw must be positive")
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
def get_balance(self) -> float:
return self._balance
Notes
- _balance signals “private-ish” intent.
- All state changes go through controlled methods that enforce rules.
Java-style encapsulation (stronger)
public class BankAccount {
private String owner;
private double balance;
public BankAccount(String owner, double initial) {
this.owner = owner;
if (initial < 0) throw new IllegalArgumentException("Initial must be >=0");
this.balance = initial;
}
public synchronized void deposit(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Positive amount required");
balance += amount;
}
public synchronized void withdraw(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Positive amount required");
if (amount > balance) throw new IllegalArgumentException("Insufficient funds");
balance -= amount;
}
public double getBalance() {
return balance;
}
}
Abstraction — Model What Matters
What abstraction gives you
Abstraction hides implementation details behind a simple interface so clients depend only on what matters for their role.
Example: FileStorage interface (Java)
public interface FileStorage {
void save(String path, byte[] content) throws IOException;
byte[] read(String path) throws IOException;
boolean exists(String path);
}
Implementations
- LocalFileStorage (filesystem)
- S3FileStorage (cloud)
- InMemoryFileStorage (tests)
Clients code against FileStorage and remain agnostic to the storage backend.
Inheritance vs Composition — Prefer Composition Usually
Inheritance basics
Inheritance gives a class the behavior and properties of another. It can reduce repeated code but can create tight coupling.
Why composition often wins
Composition lets you build objects from smaller behavior components. It’s more flexible and avoids fragile base-class problems and awkward hierarchies.
Example: Bird behaviors (pseudo)
class Bird {
Wing wing;
SwimAbility swimAbility; // optional
void move() {
if (swimAbility != null) swimAbility.swim();
else wing.flap();
}
}
This supports mixing behaviors (a duck can both swim and fly) without complex inheritance.
Polymorphism — One Interface, Many Implementations
Concept
Polymorphism lets code use objects through a common interface, allowing many concrete types to be handled uniformly.
Example: Shapes in TypeScript
interface Shape {
area(): number;
draw(ctx: CanvasRenderingContext2D): void;
}
class Circle implements Shape {
constructor(public radius: number) {}
area() { return Math.PI * this.radius*this.radius; }
draw(ctx) { /* draw circle */ }
}
class Rectangle implements Shape {
constructor(public width: number, public height: number) {}
area() { return this.width*this.height; }
draw(ctx) { /* draw rectangle */ }
}
function drawAll(shapes: Shape[], ctx: CanvasRenderingContext2D) {
for (const s of shapes) s.draw(ctx);
}
drawAll calls draw without caring which concrete shape it receives.
Real-World Example 1 — Library Management System
Domain concepts
- Book: title, authors, ISBN, copies
- Copy: physical instance of a book
- Member: borrowing user
- Loan: association between Copy and Member with due date
- Catalog: search and indexing
Relationships (textual UML)
[Book] 1--* [Copy] [Member] 1--* [Loan] *--1 [Copy] [Catalog] aggregates [Book]
Implementation sketch (Python)
from datetime import date, timedelta
from typing import List, Optional
import uuid
class Book:
def __init__(self, title:str, authors:List[str], isbn:str):
self.title = title
self.authors = authors
self.isbn = isbn
self.copies = []
def add_copy(self, barcode:str = None):
c = Copy(book=self, barcode=barcode or str(uuid.uuid4()))
self.copies.append(c)
return c
def available_copies(self):
return [c for c in self.copies if c.is_available()]
class Copy:
def __init__(self, book:Book, barcode:str):
self.book = book
self.barcode = barcode
self._loan = None
def is_available(self):
return self._loan is None or self._loan.is_returned()
class Loan:
def __init__(self, copy:Copy, member:'Member', days=14):
self.copy = copy
self.member = member
self.start=date.today()
self.due=self.start + timedelta(days=days)
self.returned=None
copy._loan = self
def is_returned(self) -> bool:
return self.returned is not None
def return_copy(self):
if self.is_returned():
raise Exception("Already returned")
self.returned=date.today()
self.copy._loan=None
class Member:
def __init__(self, name:str, member_id:str):
self.name=name
self.member_id=member_id
self.loans=[]
def borrow(self, copy:Copy):
if not copy.is_available():
raise Exception("Copy unavailable")
loan=Loan(copy, self)
self.loans.append(loan)
return loan
Extending the model
Add reservations, fines, search indexing, and role-based permissions as needed. Keep operations that change state inside domain classes.
Real-World Example 2 — E-commerce Order Flow
Entities
- Product
- Cart
- Order
- PaymentProcessor (external dependency)
Important design goal
Separate domain logic from infrastructure. Depend on abstractions (interfaces) so you can replace implementations during tests or when swapping providers.
Example: PaymentProcessor interface (Java)
public interface PaymentProcessor {
PaymentResult charge(CardInfo card, Money amount);
}
OrderService accepts a PaymentProcessor so it’s testable with a fake or mock implementation.
Real-World Example 3 — Game Entities
Two common approaches
- Class hierarchy: Entity -> Player, Enemy
- Entity-Component-System (ECS): Entities are IDs; components (position, renderable, physics) add behavior. ECS favors composition and performance.
Simple class approach (C# Unity-style)
public abstract class Entity {
public Vector2 Position { get; set; }
public abstract void Update(float deltaTime);
}
public class Player : Entity {
public int Health { get; private set; }
public override void Update(float dt) {
// input handling, movement, shooting
}
}
ECS benefits
ECS decouples data from behavior, improves cache friendliness, and supports dynamic mixing of behavior.
Logger Example — Same Concept Different Languages
Interface: Logger { log(level, message) }
Python
from abc import ABC, abstractmethod
class Logger(ABC):
@abstractmethod
def log(self, level:str, msg:str) -> None:
pass
class ConsoleLogger(Logger):
def log(self, level:str, msg:str):
print(f"[{level}] {msg}")
class FileLogger(Logger):
def __init__(self, path):
self.path = path
def log(self, level, msg):
with open(self.path,"a") as f:
f.write(f"[{level}] {msg}\n")
def do_work(logger:Logger):
logger.log("INFO","Starting work")
# ...
TypeScript
interface Logger {
log(level: string, msg: string): void;
}
class ConsoleLogger implements Logger {
log(level: string, msg: string) { console.log(`[${level}] ${msg}`); }
}
class FileLogger implements Logger {
constructor(private path: string) {}
log(level: string, msg: string) {
// Node.js fs append to file
}
}
Benefit
Swap implementations without touching the code that uses Logger.
SOLID Principles — Practical Guide
Single Responsibility Principle (SRP)
A class should have one reason to change. Example: separate InvoiceCalculator from InvoicePrinter.
Open/Closed Principle (OCP)
Classes should be open for extension but closed for modification. Use interfaces or composition to add behavior.
Liskov Substitution Principle (LSP)
Subclasses must be substitutable for their base classes. Beware classic Rectangle/Square pitfalls.
Interface Segregation Principle (ISP)
Prefer many small client-specific interfaces over one large fat interface.
Dependency Inversion Principle (DIP)
Depend on abstractions, not concretions. Use dependency injection.
Design Patterns — Applied Examples
Factory Method
Choose object creation logic centrally.
public class DocumentFactory {
public static Document create(String type) {
switch(type) {
case "pdf": return new PdfDocument();
case "word": return new WordDocument();
default: throw new IllegalArgumentException();
}
}
}
Strategy
Swap algorithms at runtime (e.g., pricing strategies).
class PricingStrategy(ABC):
@abstractmethod
def price(self, order): pass
class RegularPricing(PricingStrategy):
def price(self, order): return sum(item.price for item in order.items)
class DiscountPricing(PricingStrategy):
def price(self, order): return 0.9 * sum(item.price for item in order.items)
class Order:
def __init__(self, strategy: PricingStrategy):
self.strategy = strategy
def total(self): return self.strategy.price(self)
Observer
Useful for event-driven notifications and UI updates.
Repository
Abstract persistence behind repository interfaces so domain logic is storage-agnostic.
Testing OOP Code — Unit Tests & Mocks
Approach
- Unit tests for single classes.
- Mocks for external systems (DB, network).
- In-memory implementations for integration-like tests.
Example (pytest)
def test_member_borrows_copy():
book = Book("A", ["Author"], "ISBN1")
c = book.add_copy(barcode="bc1")
member = Member("Alice","m1")
loan = member.borrow(c)
assert not c.is_available()
loan.return_copy()
assert c.is_available()
Design for testability
Inject dependencies (like FileStorage) rather than hard-coding them.
Performance Considerations
Typical issues
- Many small objects and virtual calls can add overhead.
- Deep inheritance trees can complicate optimizations.
Mitigations
- Lazy initialization for heavy objects.
- Object pooling for frequently created short-lived objects.
- Prefer composition for clearer, optimizable primitives.
- Profile before optimizing — readability first.
When Not to Use OOP
- Tiny one-off scripts or transform pipelines — procedural/functional code may be quicker.
- Heavy numerical workloads — array libraries or functional-parallel approaches are often better.
- Small utilities — avoid premature abstraction.
Refactoring Toward Better OOP
Common refactor steps
- Extract Class: move cohesive methods and data into a new class.
- Extract Interface: separate contract from implementation.
- Replace Conditional with Polymorphism: remove big switch/if blocks.
- Encapsulate Field: use getters/setters when invariants are required.
- Introduce Parameter Object: group many method parameters.
Example: shipping cost refactor
Replace large conditional with ShippingCalculator implementations:
class ShippingCalculator(ABC):
@abstractmethod
def cost(self, order): pass
class USShipping(ShippingCalculator):
def cost(self, order): return 5 + 0.1*order.weight
class CanadaShipping(ShippingCalculator):
def cost(self, order): return 10 + 0.12*order.weight
Dependency Injection — Keep Code Testable and Flexible
Why DI matters
Provide dependencies instead of creating them internally to enable testing and flexibility.
Example (pseudo)
class OrderService:
def __init__(self, payment_processor: PaymentProcessor, order_repo: OrderRepository):
self.payment_processor = payment_processor
self.order_repo = order_repo
def place(self, order, card):
result = self.payment_processor.charge(card, order.total())
if result.success:
self.order_repo.save(order)
In tests, pass a fake PaymentProcessor to simulate success/failure.
Common OOP Pitfalls and How to Avoid Them
Pitfalls
- God object: single class that does everything. Break responsibilities apart.
- Excessive inheritance: deep, hard-to-reason hierarchies. Prefer composition.
- Leaky abstractions: exposing internals through public fields. Keep invariants private.
- Premature optimization: optimize only after profiling.
- Overuse of getters/setters: if objects only expose fields, behavior becomes procedural. Prefer meaningful methods.
Advanced Topic — Immutable Objects
Benefits
Immutable objects are thread-safe and easier to reason about.
Example (Python dataclass)
from dataclasses import dataclass
@dataclass(frozen=True)
class Money:
amount: float
currency: str
Operations return new instances instead of mutating existing ones. Combining functional techniques with OOP yields robust code.
Real Example: Tiny Task Manager
Model
- Task, Project, TaskRepository, TaskService
Python sketch
from dataclasses import dataclass
from datetime import date
from typing import List
import uuid
@dataclass
class Task:
id: str
title: str
due: date
done: bool = False
def mark_done(self):
if self.done: return
self.done = True
class TaskRepository(ABC):
@abstractmethod
def add(self, task:Task): pass
@abstractmethod
def list_pending(self)->List[Task]: pass
class InMemoryTaskRepository(TaskRepository):
def __init__(self):
self._tasks=[]
def add(self, task): self._tasks.append(task)
def list_pending(self): return [t for t in self._tasks if not t.done]
class TaskService:
def __init__(self, repo:TaskRepository):
self.repo = repo
def create_task(self, title, due):
t = Task(id=str(uuid.uuid4()), title=title, due=due)
self.repo.add(t)
return t
Usage
The repository can later be swapped for a DB-backed implementation without changing TaskService.
Migrating Legacy Code Toward OOP
Strategy
- Identify cohesive data and behavior — extract classes gradually.
- Create interfaces for external resources — enable testing.
- Add tests before large refactors to ensure safety.
- Refactor small pieces iteratively.
Combining OOP with Functional Concepts
Hybrid approach
Use immutable value objects and pure functions for transformations while using OOP for domain models. This mix gives testability and clarity.
Example
A pure function that maps a User domain object to a DTO reads immutable fields and performs transformations without side effects.
Practical Checklist: Writing Good OOP Code
- Keep classes small and focused.
- Favor composition over inheritance.
- Program to interfaces/abstractions.
- Keep invariants inside classes (encapsulation).
- Make side effects explicit.
- Use domain-specific names.
- Add unit tests for core logic.
- Apply patterns when they solve a real problem.
- Keep constructors simple — use builders/factories for heavy setup.
Example Refactor: Break a God Class
Before
A ReportGenerator that reads data, formats it, writes to disk, and emails results.
After
Split into:
- DataFetcher — reads data.
- ReportFormatter — formats the report.
- ReportSaver — writes to storage.
- Notifier — sends emails.
Smaller classes are easier to test and change.
OOP in Microservices and Serverless
Relevance
OOP remains useful: domain models, service boundaries, DTOs. But avoid sharing domain classes across service boundaries. Design services around business capabilities with clear APIs, versioning, and language-agnostic contracts.
Learning Path & Exercises
Projects to build
- Contact manager (contacts, groups, import/export).
- Small blog engine (posts, comments, tags, search).
- Shopping cart with pricing strategies.
- Convert a procedural script into OOP and compare clarity.
- Small game implemented with class-based and ECS-like approaches.
For each exercise
- Write unit tests for domain logic.
- Add interfaces for external dependencies.
- Refactor after tests are in place.
Summary and Final Thoughts
Object-Oriented Programming gives a strong model for handling complex systems. Key takeaways:
- Use encapsulation to protect invariants.
- Prefer composition over deep inheritance.
- Keep interfaces small and meaningful.
- Design for testability and separation of concerns.
- Use design patterns pragmatically, not dogmatically.
OOP is a tool—apply it when it helps, avoid it when it adds unnecessary complexity.