What Is Object Oriented Programming (OOP)? The 4 Core Principles With Code Examples

Object oriented programming is a way of building software around objects that bundle data with the behaviour that works on that data. I have managed web projects since 2012, and this way of thinking is what keeps a growing codebase from falling apart. So in this guide I walk through the four core principles with Python code examples.
Those four principles are encapsulation, inheritance, polymorphism and abstraction. I use Python because the syntax is clean and a beginner can read it without friction. That said, every idea here applies equally to Java, C#, PHP or TypeScript. I am not covering SOLID in this article; it deserves its own piece. You can browse the rest of my programming articles in the software category.
What is object oriented programming (OOP)?
Object oriented programming is a programming paradigm that models a program as a set of objects sending messages to each other. In addition, each object carries its own data, called state, plus the methods that change that data. As a result, the code splits into small parts with clear responsibilities that mirror real concepts.
Think of an online shop. A cart, a product, a customer and an order are separate ideas. OOP then asks you to describe each of them as a class. The cart knows how to add items and total them. The order, meanwhile, tracks its own status. So when a bug shows up, you know which file to open first.
The roots of the paradigm go back to the Simula language in the 1960s. Smalltalk then refined the idea, and C++ and Java brought it into the mainstream. Today, for example, Python, PHP, C#, Kotlin and Swift all support classes and objects. In other words, what you learn here survives a change of language.
What is the difference between a class and an object?
A class is a template; an object is a concrete instance built from it. Picture an architect's drawing. The drawing itself is not a house, yet you can build a hundred houses from it. Likewise, a class describes the data and behaviour; the object is the real thing living in memory.
class Product:
def __init__(self, name, price):
self.name = name
self.price = price
def discounted_price(self, rate):
return self.price * (1 - rate)
pen = Product("Pen", 4)
notebook = Product("Notebook", 12)
print(notebook.discounted_price(0.25)) # 9.0
Here Product is the class. By contrast, pen and notebook are two separate objects created from it. Specifically, both share the same methods, but each keeps its own name and price. The __init__ method is the constructor that runs when Python creates the object. The self parameter also tells the method which object it works on.
If you want to check the maths by hand, my discount calculator uses the same formula. Comparing your code with the tool is a fun way to test your first class.
Why did object oriented programming appear?
In procedural code, data and functions live apart. For a small script that is fine, because nobody else touches it. As a project grows, however, hundreds of functions start changing the same shared data. As a result, predicting the impact of one change gets hard. OOP reduces that mess by placing the data next to its owner.
| Criterion | Procedural approach | Object oriented approach |
|---|---|---|
| Basic unit | Function | Class and object |
| Where does data live? | Often shared or global | Inside the object that owns it |
| Reuse | Copying functions or libraries | Inheritance and composition |
| Impact of a change | Can spread widely | Tends to stay inside the class |
| Best fit | Short scripts, one off jobs | Long lived, multi person projects |
This table does not say procedural code is bad. For example, writing classes for a twenty line script that cleans one CSV file is pure ceremony. On the other hand, an admin panel that needs years of maintenance gains a lot from a clear object structure.
My own observation is simple. You usually notice that a project needs objects when the third developer joins. The first two can hold the code in their heads. The newcomer, though, loses days working out which function changes which data. Classes, in other words, write that knowledge into the code itself.
What is encapsulation and what problem does it solve?
Encapsulation means hiding an object's data from the outside world and letting others reach it only through controlled methods. The point is not secrecy for its own sake. Instead, the goal is to guarantee that the object always stays in a valid state.
Consider a bank account. If anyone could edit the balance directly, someone might write minus five thousand and the system would never notice. Encapsulation, however, closes that door. Only the deposit and withdraw methods change the balance, and those methods check the rules.
In practice encapsulation gives you three benefits:
- You can change the internals without breaking the code that uses the object.
- You stop invalid data at one single point instead of repeating checks everywhere.
- You find the path that changed a value much faster while debugging.
In short, encapsulation turns the object into the guardian of its own rules. Once it becomes a habit, surprises like "how did this value go negative?" become far less common.
What does encapsulation look like in a code example?
The class below keeps the balance in a field that starts with an underscore. It exposes only a read only property:
class BankAccount:
def __init__(self, owner):
self.owner = owner
self._balance = 0
@property
def balance(self):
return self._balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Amount must be positive")
self._balance += amount
def withdraw(self, amount):
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
account = BankAccount("Alice")
account.deposit(500)
account.withdraw(200)
print(account.balance) # 300
You can read balance, yet writing account.balance = 1000 raises an error because the property has no setter. The only way to change the balance runs through deposit and withdraw. So rules like "no negative amounts" live in one place.
For instance, tomorrow you might decide to load the balance from a database. Then you change only the inside of the class. Every other piece of code keeps writing account.balance as before. In short, that is the most tangible benefit of encapsulation.
You can apply the same logic to stock levels, coupons or loyalty points. A coupon class, for instance, keeps its expiry date and usage count inside and refuses to apply itself when it is no longer valid.
Does Python really have private attributes?
No, Python has no exact equivalent of the Java private keyword. The official Python tutorial states it plainly: private instance variables that only the object itself can access do not exist in Python. Instead, you rely on a naming convention.
A name with a single leading underscore, such as _balance, signals "internal detail, hands off". With a double leading underscore, Python applies name mangling and combines the attribute name with the class name. Moreover, that mechanism exists to avoid name clashes in subclasses. It is not a security feature.
class Sample:
def __init__(self):
self.__hidden = 42
s = Sample()
print(s._Sample__hidden) # 42, still reachable
So in Python, encapsulation is a matter of discipline. Your team follows the underscore convention and nobody touches those fields from outside. In Java or C#, by contrast, the compiler enforces the rule. Both approaches share one aim: drawing a clear boundary around the internal state of an object.
What is inheritance and when should you use it?
Inheritance lets one class take over the attributes and methods of another. The class that inherits is the subclass; the one it inherits from is the superclass. The subclass does not rewrite shared behaviour. Instead, it adds or changes only what makes it different.
There is a simple test for correct use. First, can you say "is a" between the two concepts? A cat is an animal, so a Cat class that extends Animal makes sense. A car, however, is not an engine; a car has an engine. In that second case inheritance is the wrong tool.
Typical situations where inheritance fits well:
- Several classes share the same fields and most of their behaviour.
- A framework such as Django expects you to extend one of its classes.
- Subclasses extend the superclass without breaking its promises.
That said, once an inheritance chain passes three or four levels, the code gets hard to follow. You end up opening five files just to find where a method comes from. For that reason I recommend shallow hierarchies to every team I work with.
How does inheritance work in a code example?
Take the user system of a website. Every user has a name and an email address, and an admin also needs extra rights. An admin also has a list of permissions:
class User:
def __init__(self, name, email):
self.name = name
self.email = email
def introduce(self):
return f"{self.name} ({self.email})"
def can(self, action):
return False
class Admin(User):
def __init__(self, name, email, permissions):
super().__init__(name, email)
self.permissions = set(permissions)
def can(self, action):
return action in self.permissions
a = Admin("Mark", "mark@example.com", ["delete", "edit"])
print(a.introduce()) # inherited method
print(a.can("delete")) # True
Admin uses introduce without writing it, because it inherits the method from the superclass. The super().__init__ call runs the parent constructor, so you avoid repeating the name and email assignments. You also redefine the can method. We call this overriding, and it opens the door to the next principle.
A strong password policy naturally belongs in classes like these. For test accounts, you can create random values with my password generator.
When is composition a better choice than inheritance?
Composition means an object holds other objects as fields and delegates part of the work to them. It models a "has a" relationship instead of "is a". The design patterns literature repeats the same advice again and again: favour composition over inheritance where you can.
class Engine:
def start(self):
return "Engine started"
class Car:
def __init__(self, engine):
self.engine = engine
def drive(self):
return self.engine.start() + ", car is moving"
car = Car(Engine())
With this structure, you can write an electric engine class tomorrow and hand it to the car. Also, not a single line of Car changes. To reach the same flexibility with inheritance, you would need a separate car subclass for every engine type.
My practical rule is to say the relationship out loud. If "an admin is a user" sounds right, I use inheritance. If "an invoice is a PDF" sounds odd, I give the invoice a PDF builder object instead. This small habit saves you from tearing down fragile hierarchies later.
What is polymorphism in object oriented programming?
Polymorphism means that objects of different classes can answer the same method call in their own way. The word comes from Greek and means "many forms". Therefore the calling code does not need the exact type. It only needs to know that the expected method exists.
Here is an everyday example. Tell a guitar, a piano and a drum to "play" and all three make a sound, each in a different way. The person giving the command does not know how each instrument works inside. Software, likewise, hits the same situation all the time with payments, notifications or exports.
Polymorphism shows up in two common forms. First, there is runtime polymorphism, where subclasses override a superclass method. Second, languages like Java and C# let you define methods with the same name but different parameters, which we call overloading. Python does not support overloading directly, so you use default parameters instead.
The real gain is that adding a new type leaves existing code untouched. As a result, long if and elif chains give way to small, independent classes.
How do you apply polymorphism in code?
Take the checkout step of an online shop. Card payment, bank transfer and cash on delivery do different jobs. Still, the order code wants to talk to all of them the same way:
class CardPayment:
def pay(self, amount):
return f"Charged {amount} to the card"
class BankTransfer:
def pay(self, amount):
return f"Sent IBAN details for {amount}"
class CashOnDelivery:
def pay(self, amount):
return f"Will collect {amount} on delivery"
def complete_order(method, amount):
print(method.pay(amount))
for method in [CardPayment(), BankTransfer(), CashOnDelivery()]:
complete_order(method, 75)
The complete_order function never asks which payment type it has. Instead, it calls pay and each object gives its own answer. If you add a wallet payment next month, you write one new class and leave the order code alone.
Checkout design depends on user experience as much as on architecture. On my ecommerce consulting page I explain what I look for in the payment step.
Is duck typing a form of polymorphism?
Yes, most polymorphism in Python works through duck typing. In the previous example, the three payment classes shared no parent class. Even so, the same function handled all of them. Python did not check the type; it only checked whether the expected method existed.
The Python glossary definition sums it up. Rather than inspecting an object's type, you simply call its method or use its attribute. The name comes from an old saying: if it walks like a duck and quacks like a duck, treat it as a duck.
This flexibility is powerful, but it carries a risk. If one class spells the method payment instead of pay, the error only appears when that line runs. On large projects I therefore use one of two safeguards:
- Write the expected interface with type hints and typing.Protocol, then check it with a tool such as mypy.
- Define a shared abstract class and force subclasses to implement the method.
The second option leads straight to the fourth principle, abstraction.
What is abstraction and how does it differ from encapsulation?
Abstraction means putting what an object does in front and pushing how it does it into the background. You show users only the interface they need. When you drive, you use the wheel, the pedals and the brake. You never need the timing of the fuel injection, because the car hides it.XX of the fuel injection.
Many developers mix up the two ideas, since both seem to hide detail. Their focus differs, though. Abstraction is a design decision that answers "which abilities should this object offer?" Encapsulation, in contrast, is an implementation technique that answers "how do I protect the internal state?"
| Question | Abstraction | Encapsulation |
|---|---|---|
| What does it focus on? | What the object does | How the data stays safe |
| When do you think about it? | Design | Implementation |
| Python tool | abc module, Protocol | Underscore convention, property |
| Gain | Hides complexity | Blocks invalid state |
Put simply, abstraction asks "what will I show?" and encapsulation asks "what will I protect?" A good class does both.
How do you write an abstract class with a code example?
In Python you write abstract classes with the abc module from the standard library. According to the abc module documentation, you cannot create an instance of a class that still has abstract methods it has not overridden. That rule forces subclasses to honour the contract:
from abc import ABC, abstractmethod
class Notification(ABC):
def __init__(self, recipient):
self.recipient = recipient
@abstractmethod
def send(self, message):
...
def send_and_log(self, message):
result = self.send(message)
print(f"Log: {self.recipient} / {result}")
class EmailNotification(Notification):
def send(self, message):
return f"Email sent: {message}"
class SmsNotification(Notification):
def send(self, message):
return f"SMS sent: {message[:160]}"
If you try to create Notification() directly, Python raises a TypeError. That happens because send is still abstract. Meanwhile, send_and_log is a concrete method, and every subclass gets it for free.
To test length limits such as the 160 character SMS cap, my word counter comes in handy. Measure your message templates there first, then move them into code.
How do the four principles work together in one project?
Learning each principle alone is easy; the real skill is combining them. For example, look again at the notification system. All four principles play a role in that small structure:
- Abstraction: the Notification class describes only the ability to send and hides channel details.
- Inheritance: the email and SMS classes inherit the recipient field and the logging method.
- Polymorphism: the same send_and_log call gives a different result on each channel.
- Encapsulation: each channel keeps its connection details inside and never exposes them.
channels = [EmailNotification("alice@example.com"),
SmsNotification("+15550000000")]
for channel in channels:
channel.send_and_log("Your order has shipped")
This loop keeps working when you add a WhatsApp channel. You only write a new class that extends Notification. That way you grow the system without touching code you already tested. I describe the same thinking applied to the front end in my article on micro frontends.
What are the most common mistakes in object oriented programming?
In projects I have inherited over the years, the same mistakes come up again and again. Most of them do not come from missing knowledge. Instead, they come from applying the principles too much or in the wrong place:
- God class: one class that knows everything and grows to thousands of lines.
- Deep inheritance: hierarchies with five or six levels that make every lookup painful.
- Pointless getters and setters: unchecked accessors on every field, which is ceremony rather than encapsulation.
- Type check chains: long isinstance blocks where polymorphism would do.
- Premature abstraction: three layers of interfaces for a single use case.
What these mistakes share is that the code resists change. Moreover, poor structure often shows up in performance too. For instance, needless object creation and repeated queries slow a page down. To measure your site speed, see my guide on the Google Lighthouse performance test.
Are OOP and functional programming rivals?
No, most modern languages support both paradigms side by side. Functional programming focuses on immutable data and functions without side effects. Object oriented programming, in contrast, gathers state inside objects. Each, therefore, gives you strong tools for different problems.
For instance, using map, filter or list comprehensions inside a Python class feels completely natural. On the JavaScript and TypeScript side, React components moved from classes to functions and hooks over time. Even so, business rules, data models and service layers still live in classes in most projects.
My approach is pragmatic. In practice, I think functionally for data transformations and in objects for domain models. What matters is the next person who reads the code, not loyalty to one paradigm.
There is also a neat parallel on the web. The JSON-LD markup that describes a page to search engines is itself a model with types, properties and nested objects. A Product holds a Brand and an Offer. If that interests you, read my schema markup guide; class thinking makes the markup easy to read.
In what order should you learn object oriented programming?
The most common beginner mistake is memorising the four principles without writing any code. That is because these ideas only click when you try them in small projects. Based on my own experience, I suggest the order below. Treat it as a starting point, not a fixed curriculum:
- Get comfortable with classes, objects, constructors and self through tiny examples.
- Try encapsulation with a bank account or stock class.
- Apply inheritance with a two level hierarchy such as user and admin.
- Rewrite that hierarchy with composition and compare the result.
- Build polymorphism into a payment or notification scenario.
- Write an abstract class with the abc module and make subclasses follow it.
- Finally, move on to the SOLID principles and basic design patterns.
I also suggest writing a small test at every step. For example, proving that "a negative amount must raise an error" teaches you why encapsulation matters far better than any definition.
A small exercise: how do you write a slug generator class?
To practise, pick a familiar web task: turning a title into a URL slug. It suits encapsulation well, because the conversion rules should stay hidden from callers:
import re
class SlugGenerator:
def __init__(self, separator="-"):
self._separator = separator
def make(self, title):
text = title.lower()
words = re.findall(r"[a-z0-9]+", text)
return self._separator.join(words)
print(SlugGenerator().make("What Is Object Oriented Programming?"))
The output is what-is-object-oriented-programming. After that, extending it is up to you. You could write a subclass that handles accented letters, or switch the separator to an underscore. Compare your output with my slug generator tool and you will quickly spot the edge cases you missed.
The exercise also teaches a little SEO. A clean URL structure also helps search engines understand a page. So this tiny class does real work in a real web project.
What does OOP knowledge give you in a web project?
A corporate website or ecommerce platform lives for years. During that time, in addition, new payment methods, new languages and new integrations arrive. A codebase that follows object oriented principles absorbs these changes as small, independent classes. A careless structure, however, breaks something else with every change.
In my web design projects I ask the development team two questions at the first meeting. Where do the business rules live, and how many files does a new feature touch? Those two answers quickly show how faithful the code is to object oriented principles.
In the end, the four principles are not a checklist; they are habits that make change cheaper. Encapsulation protects data, inheritance cuts repetition, polymorphism makes new types easy to add, and abstraction hides complexity. The natural next step is SOLID, which I cover in a separate article in the software category.




