Object-Oriented Programming with Real Examples

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

  1. Class hierarchy: Entity -> Player, Enemy
  2. 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

  1. Extract Class: move cohesive methods and data into a new class.
  2. Extract Interface: separate contract from implementation.
  3. Replace Conditional with Polymorphism: remove big switch/if blocks.
  4. Encapsulate Field: use getters/setters when invariants are required.
  5. 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

  1. God object: single class that does everything. Break responsibilities apart.
  2. Excessive inheritance: deep, hard-to-reason hierarchies. Prefer composition.
  3. Leaky abstractions: exposing internals through public fields. Keep invariants private.
  4. Premature optimization: optimize only after profiling.
  5. 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

  1. Identify cohesive data and behavior — extract classes gradually.
  2. Create interfaces for external resources — enable testing.
  3. Add tests before large refactors to ensure safety.
  4. 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

  1. Contact manager (contacts, groups, import/export).
  2. Small blog engine (posts, comments, tags, search).
  3. Shopping cart with pricing strategies.
  4. Convert a procedural script into OOP and compare clarity.
  5. 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.

#Object-Oriented Programming #OOP #OOP examples #encapsulation #abstraction #inheritance #polymorphism #SOLID principles #design patterns #composition over inheritance #dependency injection #refactoring #unit testing #software design #Java OOP

Subscribe to our newsletter

12k+

Subscribers

Weekly

Frequency

Free

Always