Tldr
Object-oriented design gives objects clear responsibilities, protects the rules around their state, and defines how they collaborate. Abstraction, encapsulation, inheritance, and polymorphism offer different ways to do this. Using classes is not enough: the design still needs sensible boundaries and compatible behaviour.
A device-backup tool needs to obtain configuration, decide whether to retry a failed request, and store the result. Those responsibilities could be organised around objects such as a configuration source, a retry policy, and a backup store.
Object-oriented programming (OOP) provides the language mechanisms for expressing such objects. Object-oriented design is the work of deciding which objects are useful, what each owns, and what callers may expect from it.
A class defines a type; an instance is an individual object of that type. Two retry-policy instances can use the same methods while holding different retry limits. Modular Design covers these basics. Here, the question is how to use them to make changes easier to manage.
Four concepts, four different jobs
| Concept | Design question | Example in a backup tool |
|---|---|---|
| Abstraction | What capability does the caller need? | Obtain configuration text without knowing how it was retrieved |
| Encapsulation | Who controls the object’s state and its rules? | A retry policy rejects invalid attempt limits |
| Inheritance | Is this a specialised type that can honour the base type’s contract? | A saved snapshot implements a configuration-source contract |
| Polymorphism | Can the same caller work with different implementations? | One reporting function reads either saved or generated configuration |
These ideas overlap, but they are not interchangeable. An abstraction does not require an abstract class, and polymorphism does not require a shared parent class.
Abstraction: define the capability callers need
Suppose the backup tool needs configuration text. A source might read a saved snapshot, call a device API, or use an in-memory value during a test. The caller needs the configuration, not the steps used to obtain it.
An abstraction captures that useful capability. It also leaves out details that callers should not depend on. A method named read() is only the beginning of the contract. The design should establish:
- whether it returns text, bytes, or a structured record
- whether an empty result is valid
- which failures callers should handle
- whether repeated calls return the same snapshot or retrieve fresh data
Hiding the implementation is useful because it limits what callers must understand. It is not about making the object’s data available indiscriminately. A good abstraction exposes the information and operations needed for its purpose.
Expressing a contract with an abstract base class
Python supports explicit abstractions through the standard-library abc module. An abstract base class (ABC) can require operations and also provide shared implementations. Classes with unresolved abstract methods cannot be instantiated. A concrete implementation inherited from another base can satisfy a requirement; every descendant need not override it again. Python documentation: Abstract Base Classes
Here is a small configuration-source contract and an in-memory implementation:
from abc import ABC, abstractmethod
class ConfigSource(ABC):
@abstractmethod
def read(self) -> str:
"""Return configuration text; an empty string is allowed."""
...
def is_empty(self) -> bool:
return not self.read().strip()
class SavedConfig(ConfigSource):
def __init__(self, text: str):
self._text = text
def read(self) -> str:
return self._text
snapshot = SavedConfig("hostname edge-01\ninterface eth0\n")
assert snapshot.is_empty() is FalseSavedConfig implements read() and inherits is_empty(). The inherited method calls the implementation of read() on the current object. No file or network connection is involved in this example.
Calling ConfigSource() would raise TypeError because read() is still abstract. Merely inheriting from ABC, without declaring or inheriting any abstract methods, does not prevent instantiation.
The ABC checks that required abstract operations have been made concrete. It does not prove that a method returns the promised value or handles errors correctly. Type checking and tests address different parts of that contract.
Encapsulation: keep state changes within the rules
An object often owns more than a collection of fields. It owns rules about which combinations of values are valid and how those values may change. Such a rule is called an invariant.
For a retry policy, assume the number of attempts must be a positive integer, including the initial attempt. If any caller can casually set that number to zero or a string, every part of the retry workflow has to defend against an invalid policy.
Instead, make the public update path check the rule:
class RetryPolicy:
def __init__(self, attempts: int):
self.attempts = attempts
@property
def attempts(self) -> int:
return self._attempts
@attempts.setter
def attempts(self, value: int) -> None:
if type(value) is not int or value < 1:
raise ValueError("Attempts must be a positive integer")
self._attempts = value
policy = RetryPolicy(3)
policy.attempts = 5
assert policy.attempts == 5The constructor uses the same setter as later updates. Both RetryPolicy(0) and policy.attempts = 0 raise ValueError. A rejected update leaves the previous value intact because validation happens before assignment.
The property preserves convenient attribute syntax while controlling the public update path. That is the role of Python’s getter and setter support; properties need not be added to every attribute. Python documentation: property
For a more involved operation, a method may communicate the intent better. approve_change(reviewer) can check authority and record an audit event; a bare status setter may not express those requirements well.
Non-public does not mean inaccessible
Python uses _name to mark a non-public implementation detail by convention. A class member such as __audit is name-mangled to a class-qualified name, for example _Device__audit. This transformation helps avoid accidental clashes with subclass members; it is not a security boundary. Deliberate access remains possible. Names such as __init__, with double underscores at both ends, are a different convention. Python tutorial: Private Variables
In the retry example, callers could bypass the property by assigning to _attempts. They should not do so. Encapsulation works through a well-defined public interface, language mechanisms, and agreement about which details are internal. It does not replace access control for sensitive data.
Abstraction and encapsulation serve related purposes: the abstraction tells callers what an object offers; encapsulation keeps responsibility for its internal state with the object.
Inheritance: preserve the base contract
Inheritance relates a derived class to a base class. It allows reuse of existing behaviour and lets a derived class override selected methods. In the earlier example, SavedConfig(ConfigSource) establishes that relationship, and is_empty() is reused without being copied. Python tutorial: Inheritance
The design test is not merely whether two classes contain similar code. Ask whether the derived object can be used wherever the base contract is expected.
For example, a new read() implementation that returns a database cursor instead of configuration text would break callers relying on string operations. It may satisfy the ABC’s requirement for a concrete method, yet still violate the behavioural contract. A subclass that unexpectedly deletes the saved configuration after reading it could cause a similar surprise.
Reusing a base class also creates dependence on its behaviour. A change to is_empty() affects subclasses that inherit it. A subclass that overrides the method may behave differently, so a base-class fix does not automatically fix every descendant. Test the implementations against the expectations their callers share.
Use composition for collaborators
A backup job uses a configuration source and a retry policy. It is not a specialised configuration source or a specialised retry policy. Holding those collaborators as attributes, or receiving them as arguments, expresses the relationship more clearly than inheriting from them.
This is composition. It lets the job combine a source with a policy without creating subclasses for every pairing, such as “API source with three attempts” and “API source with five attempts.” Modular Design compares the two relationships.
Inheritance is useful when the type relationship and shared behaviour are intentional. Composition is often a better fit when objects simply need to work together.
Polymorphism: one caller, different implementations
Polymorphism lets a caller use a common operation while different objects supply its implementation. A reporting function can call read() without selecting a separate branch for every source type.
Python’s duck typing supports this by using the operations an object provides instead of requiring a particular class. The object’s actual type does not change. What changes is what the caller depends on: behaviour rather than a specific inheritance relationship. Python glossary: Duck typing
Continuing with SavedConfig from above:
class GeneratedConfig:
def __init__(self, hostname: str):
self.hostname = hostname
def read(self) -> str:
return f"hostname {self.hostname}\n"
def count_config_lines(source) -> int:
return len(source.read().splitlines())
saved = SavedConfig("hostname edge-01\ninterface eth0\n")
generated = GeneratedConfig("lab-01")
assert count_config_lines(saved) == 2
assert count_config_lines(generated) == 1GeneratedConfig does not inherit from ConfigSource, but it provides the operation that count_config_lines() uses. The same function works with both objects. The generated configuration is deliberately minimal; the example demonstrates substitution, not a complete device configuration.
Having a method with the right name is not sufficient on its own. Its arguments, result, and behaviour must meet the caller’s expectations. Duck typing gives flexibility, not an automatic guarantee of compatibility.
Duck typing, ABCs, and protocols
Python offers several ways to describe these expectations:
| Approach | What it provides | What still needs checking |
|---|---|---|
| Duck typing | Callers use the required operations without demanding a shared base class | Missing operations and incompatible behaviour may only surface when exercised |
| Abstract base class | An explicit inheritance contract; unresolved abstract methods prevent normal instantiation | Concrete methods can still have incompatible signatures or behaviour |
typing.Protocol | A structural contract that a static type checker can check without requiring inheritance | Annotations do not automatically enforce runtime behaviour |
Protocols make the expected methods visible to type-checking tools while keeping structural flexibility. They do not require a class to inherit from the protocol to match it. Python documentation: Protocol
Choose the amount of explicitness that helps the codebase. A small internal helper may work well with ordinary duck typing. An extension API may benefit from a documented ABC. A typed application may use a protocol for replaceable collaborators, as shown in Maintaining Modularity.
Use OOP where it earns its place
Classes are useful when state, rules, and behaviour belong together. A stateless address-formatting operation may be clearer as a function. An object with dozens of unrelated responsibilities is not modular just because those responsibilities sit behind methods.
Before introducing a class or hierarchy, ask:
- What responsibility does this object own?
- Which rules must its state preserve?
- What may callers rely on, including failures and side effects?
- Can another implementation meet those expectations?
- Is inheritance expressing a real type relationship, or only avoiding a few repeated lines?
These questions belong in design discussions and code review. Tests should cover rejected state changes and shared behaviour across implementations, not just object construction.
Wider architecture decisions still include persistence, deployment, security, and communication between systems. OOP does not choose those for you. Sketch the important boundaries before implementation, then revise them as tests, integration work, and changing requirements provide evidence. There is no need to predict every future subclass before writing useful code.