Back to Posts
Contemporary coding workspace featuring Apple iMac and MacBook, exemplifying an environment where best practices for class design in object-oriented programming are applied.

Class Design Best Practices in OOP Explained

By Alyce Osbourne

Creating well-designed classes not only streamlines complexity and enhances code comprehension but also significantly contributes to a system’s scalability and robustness. By adhering to principles of good class design, developers can ensure that their software remains flexible in the face of changing requirements, thereby facilitating easier updates and extensions. Today I’ll be exploring this approach which aligns with modern software development practices that prioritize adaptability, performance, and maintainability.

from dataclasses import dataclass
from email.message import EmailMessage
from smtplib import SMTP_SSL

SMTP_SERVER = "smtp.gmail.com"
PORT = 465
EMAIL = "[email protected]"
PASSWORD = "password"


@dataclass
class Person:
    name: str
    age: int
    address_line_1: str
    address_line_2: str
    city: str
    country: str
    postal_code: str
    email: str
    phone_number: str
    gender: str
    height: float
    weight: float
    blood_type: str
    eye_color: str
    hair_color: str

    def split_name(self) -> tuple[str, str]:
        first_name, last_name = self.name.split(" ")
        return first_name, last_name

    def get_full_address(self) -> str:
        return f"{self.address_line_1}, {self.address_line_2}, {self.city}, {self.country}, {self.postal_code}"

    def get_bmi(self) -> float:
        return self.weight / (self.height**2)

    def get_bmi_category(self) -> str:
        if self.get_bmi() < 18.5:
            return "Underweight"
        elif self.get_bmi() < 25:
            return "Normal"
        elif self.get_bmi() < 30:
            return "Overweight"
        else:
            return "Obese"

    def update_email(self, email: str) -> None:
        self.email = email
        # send email to the new address
        msg = EmailMessage()
        msg.set_content(
            "Your email has been updated. If this was not you, you have a problem."
        )
        msg["Subject"] = "Your email has been updated."
        msg["To"] = self.email

        with SMTP_SSL(SMTP_SERVER, PORT) as server:
            # server.login(EMAIL, PASSWORD)
            # server.send_message(msg, EMAIL)
            pass
        print("Email sent successfully!")


def main() -> None:
    # create a person
    person = Person(
        name="John Doe",
        age=30,
        address_line_1="123 Main St",
        address_line_2="Apt 1",
        city="New York",
        country="USA",
        postal_code="12345",
        email="[email protected]",
        phone_number="123-456-7890",
        gender="Male",
        height=1.8,
        weight=80,
        blood_type="A+",
        eye_color="Brown",
        hair_color="Black",
    )

    # compute the BMI
    bmi = person.get_bmi()
    print(f"Your BMI is {bmi:.2f}")
    print(f"Your BMI category is {person.get_bmi_category()}")

    # update the email address
    person.update_email("[email protected]")


if __name__ == "__main__":
    main()

Simplifying class structures

Tackling large classes that have multiple responsibilities can be quite the talk, and almost always will make it more difficult for developer to understand, test and maintain. When a class has too many responsibilities it becomes prone to errors and bugs. This complexity tends to obfuscate the implementation and intention of the class, they can also hide other issues such as those affecting performance and overall the code becomes highly delicate to change.

To tackle these issues, it’s a good idea to consider breaking down monolithic classes into smaller, more manageable components, where each component focuses on a single responsibility. This approach helps organize the code, making it clearer and reducing redundancy. Smaller classes are much easier to understand and test, which enhances the overall quality and maintainability of the code.

Improving usability

To enhance the user-friendliness of classes, it’s crucial to focus on developing an intuitive interface for their attributes and methods. An intuitive interface involves designing elements that clearly convey their purposes and applications. This clarity and consistency in design make the classes more accessible and contribute to a coherent code structure.

As Guido himself said, code is more often read than written. When code is well structured, includes type hints, and has a clear purpose, it becomes much easier to reason about and utilize.

Furthermore, it’s essential to ensure that classes align with the overall structure of the codebase. This facilitates seamless integration with other components, improving the efficiency and maintainability of the entire system. When classes are designed to fit within a larger ecosystem, it streamlines development processes and encourages a modular approach to software construction. This modularity allows for easier updates, debugging, and scalability, as well-designed classes can be reused and adapted without compromising the system’s integrity. Hence, the emphasis on intuitive interfaces and alignment with the codebase highlights the significance of thoughtful class design in creating user-friendly and cohesive software solutions.

Implementing dependency injection

Adopting a strategy where dependencies are passed as parameters to classes instead of being hardcoded within them can greatly enhance the modularity and adaptability of software systems. This approach, known as dependency injection, promotes a more flexible design architecture, making it easier to reuse and reconfigure components with minimal effort. This is particularly advantageous in complex systems where dependencies can vary significantly across different environments or use cases. Externalizing dependencies allows developers to swap out implementations effortlessly without needing to make changes to the class internals, resulting in a cleaner and more loosely coupled codebase.

Furthermore, this shift towards dependency injection simplifies the software testing process. When dependencies are injected, it becomes much easier to introduce mock objects or stubs in place of real dependencies during testing phases. This speeds up the testing process by eliminating the need for intricate setup or access to external resources and improving test reliability and granularity. Test cases become more focused, targeting specific behaviors without being entangled in the complexities of the actual dependencies. As a result, developers can achieve more comprehensive test coverage with less effort, leading to higher-quality software that is more resilient to changes and easier to maintain over time.

Limiting public interfaces

Designing classes to only reveal what’s necessary for the user can be done by using _ and ** prefixes to denote private members in Python. It should be noted that the ** prefix is not a security feature; it only changes the name to avoid conflicts. Following the convention of using the _ prefix is considered good practice and relies on the community to respect these boundaries. It’s advisable to use immutable data structures for attributes that should not be modified after initialization. This approach helps prevent accidental modifications and simplifies the testing process.

Choosing the right structure

While classes are powerful, they may not always be the best choice for every programming task. Sometimes, using modules is more suitable, especially when you have a group of static functions that do not need to share or manipulate an object’s state. Modules act as containers for functions that can be reused throughout your code, allowing you to logically organize functions without the extra complexity of a class. For more complex applications you can also group modules into packages. It’s often good practice to group your modules by service or feature.

Moreover, classes might be unnecessary when their sole purpose is to store data. In such cases, simpler data structures like dictionaries, sets, or tuples can often be more efficient. For example, dictionaries are perfect for storing key-value pairs, sets are great for holding an unordered collection of unique items, and tuples can store a fixed set of items. Opting for these data structures can simplify your code, making it easier to read and maintain. This approach to selecting the appropriate data structure or coding pattern can greatly improve the performance and scalability of your software.

from dataclasses import dataclass
from functools import lru_cache, partial
from typing import Protocol

from email.message import EmailMessage
from smtplib import SMTP_SSL

SMTP_SERVER = "smtp.gmail.com"
PORT = 465
EMAIL = "[email protected]"
PASSWORD = "password"


def create_email_message(to_email: str, subject: str, body: str) -> EmailMessage:
    msg = EmailMessage()
    msg.set_content(body)
    msg["Subject"] = subject
    msg["To"] = to_email
    return msg


def send_email(
    smtp_server: str,
    port: int,
    email: str,
    password: str,
    to_email: str,
    subject: str,
    body: str,
) -> None:
    msg = create_email_message(to_email, subject, body)
    with SMTP_SSL(smtp_server, port) as server:
        # server.login(email, password)
        # server.send_message(msg, email)
        print("Email sent successfully!")



class EmailSender(Protocol):
    def __call__(self, to_email: str, subject: str, body: str) -> None:
        ...


@lru_cache
def bmi(weight: float, height: float) -> float:
    return weight / (height**2)


def bmi_category(bmi_value: float) -> str:
    if bmi_value < 18.5:
        return "Underweight"
    elif bmi_value < 25:
        return "Normal"
    elif bmi_value < 30:
        return "Overweight"
    else:
        return "Obese"


@dataclass
class Stats:
    age: int
    gender: str
    height: float
    weight: float
    blood_type: str
    eye_color: str
    hair_color: str


@dataclass
class Address:
    address_line_1: str
    address_line_2: str
    city: str
    country: str
    postal_code: str

    def __str__(self) -> str:
        return f"{self.address_line_1}, {self.address_line_2}, {self.city}, {self.country}, {self.postal_code}"


@dataclass
class Person:
    name: str
    address: Address
    email: str
    phone_number: str
    stats: Stats

    def split_name(self) -> tuple[str, str]:
        first_name, last_name = self.name.split(" ")
        return first_name, last_name

    def update_email(self, email: str, send_message: EmailSender) -> None:
        self.email = email
        send_message(
            to_email=email,
            subject="Your email has been updated.",
            body="Your email has been updated. If this was not you, you have a problem.",
        )


def main() -> None:
    # create a person
    address = Address(
        address_line_1="123 Main St",
        address_line_2="Apt 1",
        city="New York",
        country="USA",
        postal_code="12345",
    )
    stats = Stats(
        age=30,
        gender="Male",
        height=1.8,
        weight=80,
        blood_type="A+",
        eye_color="Brown",
        hair_color="Black",
    )
    person = Person(
        name="John Doe",
        email="[email protected]",
        phone_number="123-456-7890",
        address=address,
        stats=stats,
    )
    print(address)

    bmi_value = bmi(stats.weight, stats.height)
    print(f"Your BMI is {bmi_value:.2f}")
    print(f"Your BMI category is {bmi_category(bmi_value)}")

    send_message = partial(
        send_email, smtp_server=SMTP_SERVER, port=PORT, email=EMAIL, password=PASSWORD
    )
    person.update_email("[email protected]", send_message)


if __name__ == "__main__":
    main()

Final thoughts

The well-thought-out design of classes is really important when it comes to efficient software development. By following these principles, you can make your classes more user-friendly, easier to maintain, and simpler to test. If you want to learn more about improving your function design, you should definitely check out Optimize Python Code for Better Maintenance.

Improve your code with my 3-part code diagnosis framework

Watch my free 30 minutes code diagnosis workshop on how to quickly detect problems in your code and review your code more effectively.

When you sign up, you'll get an email from me regularly with additional free content. You can unsubscribe at any time.

Recent posts