Six Essential Object-Oriented Design Principles from Matthias Noback's "Object Design Style Guide"

by Damian Piatkowski 11 min read
Design Python Object-Oriented Programming
Hero image for Six Essential Object-Oriented Design Principles from Matthias Noback's "Object Design Style Guide"

The concept of transferable skills is one I hold dear, especially in programming. Good object design isn’t tied to Python, PHP, or any single language; it’s a universal philosophy. While implementation details vary from one technology to another, the underlying principles remain the same.

Limiting yourself to resources that focus exclusively on one language — for example, searching for “design books in Python” — can cut you off from a broader pool of valuable insights. Embracing general concepts, even when presented in unfamiliar languages, can greatly expand your understanding and improve your adaptability.

This is why Object Design Style Guide is worth your time, regardless of your current toolbox. It delves deeply into universal object-oriented design concepts, offering insights that transcend specific programming languages. Whether you’re working with C++, R, or any other object-oriented language, the principles in this book provide knowledge and skills that can strengthen your programming practice.

In this post, I’ll distill some of the book’s core ideas that you can bring into your own decision-making process when designing objects. If you find Python easier to read than, say, Java, you’re in luck — I’ll use it for the examples.

Principle No. 1: Favor Composition Over Inheritance

In the early days of object-oriented programming, inheritance was often seen as a cornerstone feature. Many developers relied on it heavily to promote code reuse and establish relationships between classes. Over time, however, it became clear that excessive use of inheritance could lead to rigid and confusing designs.

“In this book, inheritance plays a small role, even though it’s supposed to be a very important feature of object-oriented programming. In practice, using inheritance mostly leads to a confusing design.”

Noback’s perspective aligns with modern software design principles. In Python, inheritance is typically reserved for defining interfaces and creating hierarchies — such as custom exceptions or abstract base classes (ABCs). For most other cases, composition is the preferred approach.

Composition provides greater flexibility and encapsulation. It lets developers build complex systems by combining simpler, modular components. This not only makes code easier to manage and understand, but also reduces the tight coupling that deep inheritance hierarchies often introduce.

Instead of the usual car-and-engine examples, let’s use games — because games are fun:

class Game:
    def __init__(self, title: str, play_time: int = 0) -> None:
        self.title: str = title
        self.play_time: int = play_time


    def add_play_time(self, hours: int) -> None:
        self.play_time += hours
        print(f"Added {hours} hours to {self.title}. Total play time is now {self.play_time} hours.")


    def __str__(self) -> str:
        return f"{self.title} ({self.play_time} hours played)"


class GamerAccount:
    def __init__(self, username: str) -> None:
        self.username: str = username
        self.games: list[Game] = []


    def add_game(self, game: Game) -> None:
        self.games.append(game)
        print(f"Added game: {game.title} to {self.username}'s account.")


    def get_total_play_time(self) -> int:
        total_play_time: int = sum(game.play_time for game in self.games)
        print(f"{self.username}'s total play time across all games is {total_play_time} hours.")
        return total_play_time


    def list_games(self) -> None:
        print(f"{self.username}'s owned games:")
        for game in self.games:
            print(game)


# Usage
gamer = GamerAccount(username="PlayerOne")
game1 = Game(title="Game A", play_time=10)
game2 = Game(title="Game B", play_time=5)


gamer.add_game(game1)
gamer.add_game(game2)


gamer.list_games()
gamer.get_total_play_time()


game1.add_play_time(2)
gamer.get_total_play_time()

Here, the GamerAccount class uses composition to manage Game instances. In other words, a gamer account has games — it doesn’t inherit from a game.

This distinction is the essence of the principle:

  • Inheritance expresses an “is a” relationship (e.g. a Car is a Vehicle).
  • Composition expresses a “has a” relationship (e.g. a GamerAccount has Game objects).

By using composition, the account can track owned games and total play time without being tightly bound to the inner workings of Game. This makes the design more modular, easier to extend, and less fragile than a deep inheritance hierarchy.

Principle No. 2: Enhance Code Clarity with Named Constructors

The core idea here is to use domain-specific classes instead of primitive types (like int or str) for better abstraction and clearer intent. A powerful technique for this is the named constructor.

Named constructors are class methods that return new instances of the class. They’re often used when you need special initialization logic, input validation, or when multiple ways of creating an object make the code more expressive.

“For services, it’s fine to use the standard way of defining constructors (public function __construct()). However, for other types of objects, it’s recommended that you use named constructors. These are public static methods that return an instance. They could be considered object factories.”

Here’s an example showing how named constructors can improve readability and maintainability:

class User:
    def __init__(self, username: str, email: str):
        self.username = self._validate_username(username)
        self.email = self._validate_email(email)


    @classmethod
    def from_dict(cls, user_data: dict):
        """Create a User instance from a dictionary"""
        if 'username' not in user_data or 'email' not in user_data:
            raise ValueError("Missing username or email in user data")
        return cls(user_data['username'], user_data['email'])


    @classmethod
    def from_email(cls, email: str):
        """Create a User instance using only an email"""
        username = email.split('@')[0]
        return cls(username, email)


    @staticmethod
    def _validate_email(email: str) -> str:
        """Simple email validation"""
        if "@" in email and "." in email.split('@')[1]:
            return email
        raise ValueError("Invalid email address")


    @staticmethod
    def _validate_username(username: str) -> str:
        """Simple username validation"""
        if len(username) > 0 and username.isalnum():
            return username
        raise ValueError("Invalid username")


# Standard way of creating an instance with validation
try:
    user1 = User("john_doe", "john@example.com")
except ValueError as e:
    print(e)


# Using named constructors with validation
user_data = {'username': 'jane_doe', 'email': 'jane@example.com'}
try:
    user2 = User.from_dict(user_data)
except ValueError as e:
    print(e)


try:
    user3 = User.from_email("alice@example.com")
except ValueError as e:
    print(e)

The __init__ method sets up a User object with validation. On top of that, we’ve added two named constructors:

  • from_dict: Creates a user from a dictionary, extracting and validating the data.
  • from_email: Creates a user from just an email address, deriving a username and validating both.

This approach offers several benefits:

  • Clarity: The purpose of initialization is explicit — the method name tells you how the object is being created.
  • Flexibility: Objects can be created from different sources or input formats.
  • Encapsulation: Complex creation logic stays inside the class, in line with the single responsibility principle.
  • Consistent Validation: Validation logic is centralized in the base constructor, avoiding duplication.

Principle No. 3: Test Black, Not White Boxes

Encapsulation is a cornerstone of object-oriented design: it hides an object’s internal state and requires all interactions to go through well-defined interfaces. This promotes modularity, maintainability, and flexibility by preventing external code from depending on internal details.

Black box testing illustrates the value of encapsulation. Instead of looking inside the object, it focuses on external behavior — testing what the object does through its public interface, without knowledge of how it works internally.

“I find that developers, myself included, often tend to test classes, not objects. This may seem to be a subtle difference, but it has some pretty big consequences. If you test classes, you usually test one method of one class with all its dependencies swapped out by test doubles. Such a test ends up being too close to the implementation. You’ll be verifying that method calls are made, you’ll be adding getters to get data out of the object, etc. You could consider such tests that test classes to be white box tests, as opposed to black box tests, which are definitely more desirable. A black box test will test an object’s behavior as perceived from the outside, with no knowledge about the class’s internals. It will instantiate the object with only test doubles for objects that reach across a system boundary. Otherwise, everything is real. Such tests will show that not just a single class, but a larger unit of code, works well as a whole.”

To make the distinction between class testing and object testing more concrete, let’s consider a simple User class. This example will serve as the basis for illustrating how each testing approach works.

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email


    def update_email(self, new_email):
        # Imagine there's some complex validation here
        self.email = new_email


    def get_greeting(self):
        return f"Hello, {self.name}!"

Class Testing (White Box)

Here the focus is on individual methods, often with mocks or stubs. The tests are tightly coupled to implementation details:

import unittest
from unittest.mock import MagicMock


class TestUserClass(unittest.TestCase):
    def test_update_email(self):
        user = User("Alice", "alice@example.com")
        user.update_email = MagicMock()

        user.update_email("newalice@example.com")

        user.update_email.assert_called_with("newalice@example.com")

    def test_get_greeting(self):
        user = User("Alice", "alice@example.com")
        self.assertEqual(user.get_greeting(), "Hello, Alice!")

This verifies that a method was called — but it doesn’t actually check if the object behaves correctly after the call.

Object Testing (Black Box)

Here the object is treated as a whole. The tests focus on outcomes, not internals:

import unittest


class TestUserObject(unittest.TestCase):
    def test_update_email(self):
        user = User("Alice", "alice@example.com")
        user.update_email("newalice@example.com")

        # Testing the behavior, not the internal implementation
        self.assertEqual(user.email, "newalice@example.com")

    def test_get_greeting(self):
        user = User("Alice", "alice@example.com")
        self.assertEqual(user.get_greeting(), "Hello, Alice!")


if __name__ == '__main__':
    unittest.main()

By relying on encapsulation, black box tests are more robust and maintainable. They validate expected behavior through the public interface, rather than fragile implementation details. This makes your test suite a better reflection of real-world usage and ensures your codebase stays adaptable as it evolves.

Principle No. 4: Design for Dependency Injection

Dependency injection is a core design principle that encourages providing a class’s dependencies from the outside, usually through the constructor. While Python does not enforce strict constructor injection like languages such as Java, following this principle leads to better code organization and testability.

By injecting dependencies via the constructor, a class becomes self-contained and ready to use immediately upon instantiation. This approach reduces the risk of runtime errors from missing dependencies and results in more robust, maintainable code.

“Don’t use setter injection; only use constructor injection.”

Constructor injection ensures that all dependencies are available when the object is created, avoiding issues from partially constructed objects.

class UserService:
    def __init__(self, user_repository):
        self.user_repository = user_repository

    def get_user(self, user_id):
        return self.user_repository.get_user_by_id(user_id)

In this example, UserService depends on a user_repository object, which is injected through the constructor. This design allows user_repository to be easily replaced with a different implementation or mocked for testing without modifying the UserService class.

Typically, user_repository would be another class responsible for managing user data storage and retrieval — such as a repository or data access layer. By injecting it, UserService can leverage its functionality without creating it internally, promoting separation of concerns and making the code more modular and easier to test.

However, even with proper constructor injection, hidden dependencies can still exist.

“If all of your dependencies and configuration values have been properly injected as constructor arguments, there may still be room for hidden dependencies. They are hidden, because they can’t be recognized by taking a quick look at the constructor arguments.”

To address this, it’s essential to document and review all dependencies thoroughly, ensuring they are explicit and clear.

Principle No. 5: Create Effective Test Doubles Without Mocking Libraries

A key insight from Noback’s book is to avoid over-reliance on mocking frameworks when creating test doubles. Mocking tools can tightly couple tests to implementation details, making them harder to read, maintain, and refactor.

Instead, manually creating fakes and stubs as separate classes leads to more explicit and maintainable test code, as each class clearly demonstrates its intended behavior.

“An important characteristic of stubs and fakes is that in a test scenario, you can’t and shouldn’t make any assertions about the number of calls made to them, or the order in which those calls are made. Given the nature of query methods, they should be without side effects, so it should be possible to call them any number of times, even zero times. Making assertions about calls made to query methods leads to tests that don’t keep sufficient distance from the implementation of the classes they’re testing.”

For example, we can create a FakeGameRepository to simulate a game repository and a StubGameNotifier to simulate notifications. This keeps tests decoupled from the underlying implementations, making them easier to refactor and read:

class FakeGameRepository:
    def __init__(self):
        self.games = {"Game A": 10, "Game B": 5}


    def get_play_time(self, title: str) -> int:
        return self.games.get(title, 0)




class StubGameNotifier:
    def notify(self, message: str) -> None:
        print(f"Stub notification: {message}")


def test_game_play_time():
    repo = FakeGameRepository()
    notifier = StubGameNotifier()

    play_time = repo.get_play_time("Game A")
    notifier.notify(f"Play time for Game A: {play_time} hours")

    assert play_time == 10


test_game_play_time()

When it comes to dummies — simple test doubles used only to satisfy method parameters without behavior — mocking tools are still useful. They reduce boilerplate and simplify setup without introducing significant drawbacks:

from unittest.mock import Mock


def test_with_dummy():
    dummy_repo = Mock()
    dummy_notifier = Mock()
    dummy_repo.get_play_time.return_value = 0

    play_time = dummy_repo.get_play_time("Non-existent Game")
    dummy_notifier.notify(f"Play time for Non-existent Game: {play_time} hours")

    dummy_notifier.notify.assert_called_with("Play time for Non-existent Game: 0 hours")


test_with_dummy()

By combining these approaches, developers can create tests that are clear, maintainable, and easy to refactor. Manually creating fakes and stubs enhances readability and reduces tight coupling, while using mocking tools for dummies streamlines setup without compromising code quality.

Principle No. 6: Dispatch Events for Secondary Tasks

Noback emphasizes the importance of keeping command methods focused and using events for secondary tasks. When designing methods, it’s crucial to ensure they don’t try to do too much. Ask yourself: Does the method name contain “and,” hinting it performs multiple tasks? Could part of its work be handled in a background process? By breaking large methods into smaller, focused ones and delegating secondary tasks to events, you can improve both clarity and maintainability.

“The advantage of using an event dispatcher is that it enables you to add new behavior to a service without modifying its existing logic. Once it’s in place, an event dispatcher offers the option to add new behavior. You can always register another listener for an existing event.”

Using events has several advantages:

  • You can add new behavior without modifying the original method.
  • The original object remains decoupled, without dependencies injected solely for side effects.
  • Secondary effects can run in background processes if needed.

However, there’s a trade-off: the primary action and its side effects may end up scattered across the codebase, making it harder to follow what happens.

“A disadvantage of using an event dispatcher is that it has a very generic name. When reading the code, it’s not very clear what’s going on behind that call to dispatch(). It can also be a bit difficult to figure out which listeners will respond to a certain event. An alternative solution is to introduce your own abstraction.”

To mitigate this, make sure the team understands that events exist to decouple parts of the system, and always dispatch them explicitly. A call to EventDispatcher.dispatch() should act as a strong signal that additional behavior will follow.

For example, imagine a gamer unlocking achievements while playing. Instead of one method both adding the achievement and notifying the user, an event dispatcher can handle the notification:

class Achievement:
    def __init__(self, title):
        self.title = title


class AchievementUnlockedEvent:
    def __init__(self, gamer, achievement):
        self.gamer = gamer
        self.achievement = achievement


class EventDispatcher:
    def __init__(self):
        self.listeners = {}


    def register(self, event_type, listener):
        if event_type not in self.listeners:
            self.listeners[event_type] = []
        self.listeners[event_type].append(listener)


    def dispatch(self, event):
        for listener in self.listeners.get(type(event), []):
            listener(event)


class GamerAccount:
    def __init__(self, username, dispatcher):
        self.username = username
        self.achievements = []
        self.dispatcher = dispatcher


    def add_achievement(self, achievement):
        self.achievements.append(achievement)
        print(f"Achievement '{achievement.title}' added to {self.username}'s account.")
        event = AchievementUnlockedEvent(self, achievement)
        self.dispatcher.dispatch(event)


def notify_user(event):
    print(f"Notification: Congratulations {event.gamer.username}! You have unlocked the '{event.achievement.title}' achievement!")


# Usage
dispatcher = EventDispatcher()
dispatcher.register(AchievementUnlockedEvent, notify_user)


gamer = GamerAccount(username="PlayerOne", dispatcher=dispatcher)
achievement = Achievement(title="First Blood")


gamer.add_achievement(achievement)

Here, the GamerAccount class focuses only on adding the achievement, while the event system handles notifications. This decoupling makes the code easier to extend — for example, adding analytics or logging later — without modifying the original method.

Final Reflections

While these are not the only valuable concepts in Noback’s Object Design Style Guide, the six principles I’ve highlighted are my personal standouts. They provide practical guidance to strengthen your programming skills and help you design robust, maintainable systems across languages. Embracing them will make you a more versatile and effective developer.