Deep Engineering #15: Steven F. Lott on Pragmatic Object-Oriented Python
When a class earns its keep: pragmatic heuristics for Python OOP.
Welcome to the fifteenth issue of Deep Engineering.
In most systems, data outlives code; object orientation is one disciplined way to model it.
In this issue, we zoom in on object oriented programming (OOP) through the lense of pragmatic Python practice—how to move from a blank screen to working types, when a class earns its keep, and how to avoid boilerplate while keeping invariants intact. These choices have a significant impact in production: clearer models reduce duplication, make change cheaper, and keep behavior close to the state it governs.
For this issue, we’ve collaborated with Steven F. Lott who has been programming since computers were large, expensive, and rare. Since the 1990s, Lott has been engaged with Python, crafting an array of indispensable tools and applications. He is also the author of notable titles like Mastering Object-Oriented Python (Packt, 2019), Modern Python Cookbook (Packt, 2024), and Python Real-World Projects (Packt, 2023). Lott is currently working on the 5th Ed. of Python Object-Oriented Programming due later this year.
What’s inside:
A feature that lays out working heuristics for object design in Python: sketch the data, establish clear boundaries, and let usage pressure reveal new types. It shows where
@dataclass
, properties, and small refactors pay off—without boilerplate.Two companion readings by Lott that take the ideas into code: Chapter 5: When to Use Object-Oriented Programming from Python Object-Oriented Programming, 4th Ed. (Packt, 2021) and Getting Started with Object-Oriented Programming in Python (Part 1).
Let’s get started.
Object-Oriented Python: Pragmatic Patterns and Pitfalls with Steven F. Lott
One of the hardest parts of adopting object-oriented programming (OOP) is figuring out where to start. The blank screen can be intimidating, especially after working through neatly packaged tutorials. A practical first step is focusing on the data your program will manage. Before diving into class hierarchies, sketch out what information you need to represent and how that data will be used. For example, if you’re modeling a card game, start by listing basic elements like cards, decks, and hands, along with their attributes (rank, suit, etc.). This data-first strategy reflects a fundamental truth: in a software project, the data outlives the code interface. UIs, scripts, and functions can change, but the core information (files, databases) usually persists.
Data-First Approach to Designing Classes
In Python, a convenient way to quickly translate a data outline into code is to use the @dataclass
decorator. A dataclass automatically generates an initializer and other useful methods from a simple class definition of attributes and type hints. This allows you to prototype classes rapidly without writing boilerplate. For instance, if we’re modeling playing cards, we can start simple:
from dataclasses import dataclass
@dataclass
class Card:
rank: int
suit: str
This snippet defines a basic Card
class with two attributes. Python’s naming conventions suggest using CapitalizedWord
for class names (sometimes called PascalCase) and lowercase with underscores for attribute names. Don’t agonize over getting the design perfect on the first try – treat these initial classes as throwaways or drafts. The goal is to get something working in code to avoid analysis paralysis. As Lott puts it, there’s “no royal road” to mastering OOP – you learn by building, testing, and iterating.
Iterate and Refine: From Draft to Design
With preliminary classes in place, start experimenting with usage to validate your design. Write small functions or scripts to create objects and exercise their behavior. Continuing our card game example, we might generate a deck of cards and print them out to see if the output makes sense. This often reveals design flaws or missing pieces. In our initial Card
class above, printing a card would yield an output like Card(rank=7, suit='♢')
, which is informative but not very user-friendly. We can improve this by adding a custom __str__
method for pretty-printing:
def __str__(self) -> str:
names = {1: "A", 11: "J", 12: "Q", 13: "K"}
rank_display = names.get(self.rank, str(self.rank))
return f"{rank_display:>2s}{self.suit}"
Now a print(card)
will display a nicely formatted string like J♠
instead of a verbose dataclass representation. This kind of refinement aligns with the Interface Segregation Principle: each class should present a clear and minimal interface to collaborators. By encapsulating display logic inside Card.__str__
, we free other parts of the program (like a Deck or Hand class) from knowing the formatting details.
Another common discovery during iteration is the need to move functionality into the class to encapsulate behavior with data. In our card example, we might have initially computed point values for cards (say, face cards count as 10 points) outside the class. If that calculation is always the same, it belongs in the class definition. One elegant way to do this in Python is with the @property
decorator, which lets you define a method that looks like a regular attribute when accessed:
@dataclass
class Card:
rank: int
suit: str
@property
def points(self) -> int:
return 10 if self.rank >= 11 else self.rank
Here, Card.points
is defined as a property that derives the point value from the rank. Now other code can simply use card.points
instead of calling a method or doing the logic externally. This improves encapsulation and readability – the card “knows” how many points it’s worth, and the rest of the program doesn’t need to duplicate that logic or remember the rule.
As you flesh out the classes, be prepared to add or split classes to narrow their responsibilities. In our card game, we treated a hand of cards as a simple list (List[Card]
) at first. But if we find ourselves writing a lot of code to manipulate or display hands (sorting, pretty-printing collections of cards, etc.), that’s a sign we need a Hand
class. It might start as simple as:
from dataclasses import dataclass
@dataclass
class Hand:
cards: list[Card]
def __str__(self) -> str:
return ', '.join(str(c) for c in self.cards)
This Hand
class wraps a list of Card
objects and provides its own __str__
to print the hand neatly. By refactoring in this way (sometimes called composition – a hand is composed of cards), we reduce duplication and make the code more expressive. The overarching lesson is to evolve your design iteratively: get something working, review it, and refactor when an object’s responsibilities start getting tangled or stretched.
Recognizing When to Use a Class
Python gives you many ways to represent data and behavior. Not everything needs to be a class. A key skill in OOP (and a theme of Lott & Phillips’ discussion in Python Object-Oriented Programming) is knowing when a class is warranted. A good rule of thumb: use classes when you have a bundle of state and related behavior that benefit from being encapsulated together. If you have data with no behaviors, a simple tuple, list, or dictionary might do. If you have behavior with no state (just input-output transformation), a standalone function could be clearer.
For example, to compute the perimeter of a polygon, you could store the vertices as a list of coordinate pairs (tuples) and write a function perimeter(polygon)
that calculates the sum of distances. This functional approach is concise:
from math import hypot
# polygon represented as list of (x, y) points
square = [(1,1), (1,2), (2,2), (2,1)]
def distance(p1, p2):
return hypot(p1[0] - p2[0], p1[1] - p2[1])
def perimeter(poly):
return sum(distance(p1, p2) for p1, p2 in zip(poly, poly[1:]+poly[:1]))
print(perimeter(square)) # 4.0
Here, using a class might actually complicate things – a simple data structure plus function is sufficiently clear. On the other hand, if we need to support many operations on polygons (area, moving/scaling, checking convexity, etc.), wrapping data and methods into a Polygon
class starts to pay off. The class can hold the list of points and provide multiple methods (perimeter, area, etc.), keeping all polygon-related logic in one place. The number of related operations and the importance of invariants (like ensuring the polygon is always closed) factor into the decision. As a general principle, start simple with Python’s built-in types; introduce custom classes when they make the code more understandable or maintainable. Remember that code length isn’t the ultimate measure – sometimes a few extra lines in a class definition greatly improve clarity, as long as you’re not writing needless boilerplate.
Keeping It Pythonic: Properties and Direct Access
Developers coming from other languages often follow a rigid getter/setter protocol for every attribute. In Python, this is usually unnecessary. You can expose attributes directly and only intervene when needed. Python’s philosophy is that “we’re all adults here” – if someone accesses a variable prefixed with _
(by convention meaning “internal use”), they should know what they’re doing. Instead of writing verbose get_foo()
and set_foo()
methods for each attribute, Python encourages the use of @property
to manage access gracefully when requirements evolve.
Suppose we have a Color
class with an RGB code and a name. Initially, direct access is fine:
class Color:
def __init__(self, rgb_value: int, name: str) -> None:
self.rgb_value = rgb_value
self.name = name
If later we decide to enforce that name
cannot be empty, we can convert name
into a property without changing the external interface:
class Color:
def __init__(self, rgb_value: int, name: str) -> None:
self._rgb_value = rgb_value
self._name = name # underscore to indicate internal
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, new_name: str) -> None:
if not new_name:
raise ValueError("Color name cannot be empty")
self._name = new_name
Client code that uses c = Color(0xff0000, "red")
and then c.name
or c.name = "crimson"
will continue to work, but now with validation. The ability to retrofit logic with properties helps maintain encapsulation without imposing a clunky API from the start. Use methods for actions (verbs) and use attributes/properties for state (nouns/adjectives). This keeps your code Pythonic and clear. Properties are also handy for lazy computations or caching. For instance, you might fetch and cache a web page’s content on first access, then reuse it on subsequent accesses – all behind the scenes of a content
property so that calling code isn’t aware of the caching mechanism.
Don’t Repeat Yourself: OOP for Reuse
A major promise of OOP is reducing code repetition by abstracting common functionality. If you find yourself copying and pasting code, take a step back – could that logic live in a class (or base class) instead? For example, imagine we write a utility to find and replace text in all files within a ZIP archive. We might start with a script or class ZipReplace
that unzips a file, performs replacements on each text file, and zips it back up. Later, we realize we need a similar tool to, say, resize all images in a ZIP. Rather than duplicating the unzip/iterate/zip logic, we can refactor the common parts into a general framework class (let’s call it ZipProcessor
) and have specialized subclasses or hooks for the specific transformation (text replace or image resize).
Lott demonstrates this pattern by turning a specific ZipReplace
class into a more flexible processor that accepts any operation. The key steps were:
Identify the shared structure (backup the original archive, open it, loop through files, write out a new archive).
Parameterize or abstract the variable part (what we do to each file’s content).
Use inheritance or composition so that new behaviors (like image scaling) can be added without modifying the core logic.
Whether you choose inheritance (subclass and override a method) or composition (pass in a function/object to do the file transformation) depends on context, but both approaches serve the DRY principle (Don’t Repeat Yourself). The result is cleaner, more maintainable code. When a bug in the archive handling is fixed, it benefits all uses of ZipProcessor
. When a new operation is needed, you write only the new parts. This illustrates a broader point: prefer to refactor and generalize only after you have two or more concrete cases. It’s easier to design an abstraction when you’ve seen a couple of real examples. Premature abstraction can lead to needlessly complicated designs. OOP is a tool to manage complexity, but it’s not a silver bullet – sometimes a simple function or script is more straightforward until requirements grow.
Pragmatism Over Purism
Object-oriented programming in Python is less about rigid rules and more about practical design choices. Start with the data that matters. Build simple classes using tools like dataclasses to avoid boilerplate. Grow your design incrementally, using properties and dunder methods (like __str__
) to polish the interface as you go. Always ask: do I truly need a class here, or will basic data structures do? Embrace Python’s flexibility – you can mix and match procedural and OO styles in one program, using whatever makes the code most understandable.
Finally, keep an eye on duplication and coupling. Well-designed classes can eliminate redundant code and make your software easier to extend. But over-engineering is a trap: every added class or pattern should earn its keep by simplifying the rest of the code. The beauty of Python’s take on OOP is that it doesn’t force you into one paradigm. As you read through Lott’s deep dive on OOP fundamentals and advanced patterns, you’ll see this theme again and again: great Python code finds the right balance between simplicity and abstraction.
Next steps
Object orientation pays off when it sharpens your data model and reduces duplication. If that’s the spirit you want to carry into practice, the two pieces in our Expert Insight section that follows move from framing to executable patterns in Python, with clear heuristics for when not to use a class.
💡Key Takeaways
Start with the data that survives UI churn; model it first, code second.
Use
@dataclass
to get moving; promote derived values to@property
when collaborators shouldn’t reimplement the logic.Let usage pressure reveal new types (e.g., replace
list[Card]
with aHand
that owns formatting/behavior).Prefer functions and built-ins until state + behavior clearly cohere; code length is not a proxy for complexity.
In Python, default to direct attribute access; reach for properties when you need validation, caching, or lazy computation.
Eliminate copy-paste via reusable processors (inheritance or composition)—abstract only after two concrete cases.
❓Bring these questions to your codebase
What are the durable data shapes here—and do their behaviors belong beside them?
Which “helper” functions are re-implementing object logic and should become properties or methods?
Where am I copying workflows (e.g., I/O loops) that deserve a reusable processor with a pluggable transform?
If I removed a class and used built-ins + functions, would the code get clearer—or risk scattering invariants?
🧠Expert Insight
When to Use Object-Oriented Programming (Chapter 5, Python Object-Oriented Programming, 4e) by Steven Lott & Dusty Phillips
Decision rules for classes vs. tuples/lists + functions; Pythonic encapsulation via properties instead of boilerplate getters/setters; DRY by generalising a ZIP file processor; managers/façades to coordinate subsystems.When to Use Object-Oriented Programming
·In previous chapters, we've covered many of the defining features of object-oriented programming. We now know some principles and paradigms of object-oriented design, and we've covered the syntax of object-oriented programming in Python.
Getting Started with Object-Oriented Programming in Python (Part 1) by Steven Lott
A data-first path out of the blank screen:@dataclass
to prototype,@property
for derived state,__str__
to make objects readable, and the moment to introduceHand
/Deck
rather than pushing logic into lists.Part 1: Getting Started with Object-Oriented Programming in Python
·This is Part 1 of 2. In Part 2, we’ll look at immutability, hashability, and dependency management: when to switch from @dataclass to NamedTuple or frozen=True, how TypedDict fits a schema-first workflow, and how Protocols enable painless dependency injection.
Sponsored:
🛠️Tool of the Week
Upgrading to Apache Airflow® 3 will give you access to powerful new features like a modernized UI, event-based scheduling, and streamlined backfills.
Here is a FREE GUIDE with Airflow 3 Tips & Code Snippets to help you start developing DAGs in Airflow 3 today.
You’ll learn:
How to run Airflow 3 locally (with dark mode) and navigate the new UI
How to manage DAG versioning and write DAGs with the new @asset-oriented approach
The key architectural changes from Airflow 2 to 3
📎Tech Briefs
The 2024 Python Developer Survey Results are here!: Results show rapid uptake of 3.12/3.13 and little EOL use; work spans web, data, and ML where FastAPI/Django, pandas/NumPy, and scikit-learn/PyTorch dominate; VS Code and PyCharm lead editors; venv and pip (with growing uv and pyproject.toml) shape packaging; containers/Kubernetes are common in the cloud; AI tools are rising for discovery.
Python 3.14.0rc2 and 3.13.7 are go!: On Aug 14, 2025, Python shipped an early 3.14.0rc2 (ABI unchanged; rc3 due Sep 16, final Oct 7) after a .pyc magic-number bump, highlighting free-threaded Python (PEP 779), deferred annotations (PEP 649), t-strings (PEP 750), multiple interpreters (PEP 734), a Zstandard module, an external debugger interface, experimental JIT builds, and official Android binaries; and also released 3.13.7 to fix a TLS blocking regression.
PyApp: An easy way to package Python apps as executables: PyApp is a Rust-based tool that builds a per-project executable—bundling your wheel and a Python runtime (optionally embedded)—which unpacks itself on first run and launches the app without requiring a local Python installation.
LLMs bring new nature of abstraction — Martin Fowler: Argues that non-deterministic components change how we design interfaces and boundaries.
Tip: Use keyword-only arguments in Python dataclasses: Simon Willison highlights a small but high-leverage OOP practice from Christian Hammond—
@dataclass(kw_only=True)
—to evolve class APIs safely without breaking callers; original tip via Christian Hammond.
That’s all for today. Thank you for reading this issue of Deep Engineering. We’re just getting started, and your feedback will help shape what comes next. Do take a moment to fill out this short survey we run monthly—as a thank-you, we’ll add one Packt credit to your account, redeemable for any book of your choice.
We’ll be back next week with more expert-led content.
Stay awesome,
Divya Anne Selvaraj
Editor-in-Chief, Deep Engineering
If your company is interested in reaching an audience of developers, software engineers, and tech decision makers, you may want to advertise with us.