OOPBeginner 11 min Lesson 3 of 9

Constructor — Starting in a Valid State

Why the constructor is the best place to enforce rules, how to keep it small, and when to use alternative constructors instead.

OOP · Lesson 3 of 9
0/9 done(0%)

What is it? #

The constructor runs when an object is created. Its job is to put the object into a valid, usable state — and nothing else.

That validity guarantee is the point. If a User cannot be created without a valid email, no code downstream has to check whether the email is valid.

Keep constructors cheap. They should assign values and validate them. Making network calls, opening database connections or reading files from a constructor makes the class slow to create and painful to test.

When there are several ways to build an object — from a database row, from JSON, from a CSV line — add named alternative constructors rather than piling optional parameters onto one.

Think of it like this #

A passport office will not issue a passport with a missing photo or an unsigned form. The check happens once, at the counter, when the document is created.

The alternative — issuing anything and asking every border guard to re-verify — is what code looks like when objects can be created in invalid states.

Simple example #

A User must have a non-empty name and a plausible email. It can be created directly, from an API payload, or from a database row. Each route ends up in the same validated state.

Code #

PYTHON
from datetime import date


class User:
    def __init__(self, name: str, email: str, joined_on: date | None = None):
        name = name.strip()
        email = email.strip().lower()

        if not name:
            raise ValueError("name is required")
        if "@" not in email or email.startswith("@") or email.endswith("@"):
            raise ValueError(f"invalid email: {email!r}")

        self.name = name
        self.email = email
        self.joined_on = joined_on or date.today()
        self._roles: set[str] = set()          # sensible empty default

    # Alternative constructors — named, explicit, each doing one conversion
    @classmethod
    def from_api(cls, payload: dict) -> "User":
        return cls(
            name=payload.get("full_name", ""),
            email=payload.get("email_address", ""),
        )

    @classmethod
    def from_row(cls, row) -> "User":
        return cls(name=row["name"], email=row["email"], joined_on=row["joined_on"])

    def __repr__(self):
        return f"User({self.name!r}, {self.email!r})"


user = User("  Ravi Kumar ", "[email protected] ")
print(user)                     # User('Ravi Kumar', '[email protected]')

api_user = User.from_api({"full_name": "Anita", "email_address": "[email protected]"})
print(api_user)

try:
    User("", "nope")
except ValueError as exc:
    print("Rejected:", exc)     # Rejected: name is required


# What NOT to do in a constructor
class BadUser:
    def __init__(self, user_id):
        self.data = fetch_from_database(user_id)     # slow, needs a DB in every test
        send_welcome_email(self.data["email"])       # a side effect on creation

How it works #

The constructor normalises first — trimming whitespace and lowercasing the email — then validates. Normalising before validating means " Ravi " is accepted and stored cleanly rather than rejected or stored with spaces.

Raising ValueError with a specific message stops the object existing at all. There is no half-built User floating around.

self._roles = set() gives a sensible empty default. Note that it is created inside __init__, not as a class attribute — a class-level set would be shared by every user.

from_api and from_row are alternative constructors. Each knows about one input format and then delegates to __init__, so validation happens exactly once in one place. Returning cls(...) rather than User(...) means subclasses get the right type back.

joined_on or date.today() supplies a default without using a mutable default argument — and without freezing the date at import time, which is what joined_on=date.today() in the signature would wrongly do.

The BadUser example shows the anti-pattern. Creating that object requires a database and sends an email as a side effect, which makes it impossible to construct in a test and surprising in production.

Real-world use #

Validation at construction is what lets the rest of a codebase stay simple. Web frameworks apply it at the edge: an incoming request is parsed into a validated object, and every layer after that can rely on it.

Alternative constructors appear throughout standard libraries — datetime.fromisoformat, Decimal.from_float, DataFrame.from_records. Each converts one representation and reuses the core initialiser.

Keeping constructors free of side effects matters most in tests. A class that can be created with a line of code, with no database or network, is a class people will actually write tests for.

For data-heavy classes, Python dataclasses generate the constructor for you, and __post_init__ is the place to put validation.

Common mistakes #

  • Allowing objects to be created invalid and validating later, forcing checks everywhere.
  • Doing I/O — database, network, file — inside a constructor.
  • Using a mutable default like roles=[] in the signature; it is shared between objects.
  • Using date.today() as a default parameter value, which freezes at import time.
  • Piling up optional parameters instead of adding named alternative constructors.

Practice #

Write an Order class whose constructor requires a non-empty customer ID and at least one order line, and rejects a negative total. Add from_json and from_row alternative constructors. Then write one test proving an invalid order cannot be created.

Quick quiz

  1. 1. What is the main job of a constructor?

  2. 2. Why avoid I/O in a constructor?

  3. 3. What is an alternative constructor?

  4. 4. What is wrong with `def __init__(self, roles=[])`?

  5. 5. Why normalise input before validating it?

Summary

  • The constructor makes an object valid before anyone can use it.
  • Normalise, then validate, then assign.
  • Keep constructors free of database, network and file access.
  • Use class methods as named alternative constructors.
  • Avoid mutable defaults and values evaluated at import time.