FreeCampus Python

Keep Objects in Valid States

Turn domain rules into constructor and method checks, make failed updates atomic, and choose plain attributes, properties, methods, and alternate constructors deliberately.
python-foundations object-oriented-python invariants properties
Open in Colab
  • Level: Python Foundations
  • Estimated time: 4–6 hours
  • You will learn: Define an object’s invariants, validate construction and every mutation path, keep failed operations atomic, and choose among a plain attribute, property, method, and classmethod constructor.
  • Practice in: Google Colab, JupyterLab, or a local Python interpreter

A field robot carries a battery rated for 100 energy units. The first class is easy to write:

class Battery:
    def __init__(self, charge):
        self.charge = charge

It is also easy to put into impossible states:

battery = Battery(80)
battery.charge = -20
print(battery.charge)

Python accepts -20 because the class has not expressed any rule against it. This lesson turns rules such as “charge stays between zero and capacity” into a public interface that remains trustworthy after successful and failed calls.

Answer these questions as you work:

1. Write the rules before hiding the fields

An invariant is a condition that must be true after construction and after every public operation finishes. For this battery:

  1. capacity is greater than zero;
  2. charge is at least zero; and
  3. charge is no greater than capacity.

Write representative examples before the implementation:

# Valid examples
# Battery(capacity=100, charge=60)
# Battery(capacity=25, charge=0)
# Battery(capacity=25, charge=25)

# Invalid examples
# Battery(capacity=0, charge=0)
# Battery(capacity=100, charge=-1)
# Battery(capacity=100, charge=101)

This list prevents an implementation from validating only the pleasant case. The boundary values zero and capacity are valid; values just outside them are not.

Now validate before assigning state:

class Battery:
    def __init__(self, capacity, charge):
        if capacity <= 0:
            raise ValueError("capacity must be positive")
        if not 0 <= charge <= capacity:
            raise ValueError("charge must be between 0 and capacity")

        self._capacity = capacity
        self._charge = charge

The underscore in _charge communicates “implementation detail; use the public interface.” It does not make the value secret or inaccessible:

battery = Battery(100, 60)
print(battery._charge)

Python prints 60. The underscore is a collaboration convention, not a security boundary. The class must still keep every public operation correct.

Construction should not begin invalid

Try the boundaries deliberately:

valid = [Battery(100, 0), Battery(100, 100)]
print(len(valid))

for capacity, charge in [(0, 0), (100, -1), (100, 101)]:
    try:
        Battery(capacity, charge)
    except ValueError as error:
        print(f"{capacity=}, {charge=}: {error}")

The constructor rejects each impossible combination. It validates local inputs before assigning any attributes, which also makes the order easy to reason about.

Checkpoint: state the invariant precisely

2. Give every state change an intention-revealing operation

If callers assign _charge directly, they can bypass the rules. Give common changes names from the battery domain:

class Battery:
    def __init__(self, capacity, charge):
        if capacity <= 0:
            raise ValueError("capacity must be positive")
        if not 0 <= charge <= capacity:
            raise ValueError("charge must be between 0 and capacity")
        self._capacity = capacity
        self._charge = charge

    def drain(self, amount):
        if amount < 0:
            raise ValueError("amount cannot be negative")
        if amount > self._charge:
            raise ValueError("not enough charge")
        self._charge -= amount
        return self._charge

    def recharge(self, amount):
        if amount < 0:
            raise ValueError("amount cannot be negative")
        if self._charge + amount > self._capacity:
            raise ValueError("charge would exceed capacity")
        self._charge += amount
        return self._charge

The method names describe events, not storage mechanics. drain(8) is clearer than set_charge(get_charge() - 8), and the class gets one chance to validate the whole transition.

battery = Battery(100, 60)

assert battery.drain(15) == 45
assert battery.recharge(20) == 65

List every path that can affect the invariant:

Path Precondition State after success Failure
construction positive capacity; charge in range supplied valid values ValueError; no usable object
drain(amount) non-negative and available charge decreases ValueError; state unchanged
recharge(amount) non-negative and fits charge increases ValueError; state unchanged

If you later add reset, transfer_to, or a property setter, add that path to the audit. An invariant enforced in only one method is not an invariant.

3. Validate the whole operation before committing any of it

Suppose an expedition allocates energy from one battery to another. This implementation mutates too early:

def broken_transfer(source, target, amount):
    source.drain(amount)
    target.recharge(amount)

If the source has enough charge but the target lacks capacity, source.drain succeeds before target.recharge fails:

source = Battery(100, 50)
target = Battery(100, 95)

try:
    broken_transfer(source, target, 10)
except ValueError as error:
    print(error)

print(source._charge, target._charge)

The output is 40 95. Ten units vanished. Each object preserved its local range invariant, but the transfer operation violated the larger rule that energy moves as one transaction.

Put the operation with an owner that can validate both sides before mutation. For now a function is sufficient:

def transfer(source, target, amount):
    """Move energy atomically and return both new charges."""
    if amount < 0:
        raise ValueError("amount cannot be negative")
    if amount > source._charge:
        raise ValueError("source lacks charge")
    if target._charge + amount > target._capacity:
        raise ValueError("target lacks capacity")

    source._charge -= amount
    target._charge += amount
    return source._charge, target._charge

Check first, commit second:

source = Battery(100, 50)
target = Battery(100, 95)

before = (source._charge, target._charge)
try:
    transfer(source, target, 10)
except ValueError:
    pass

after = (source._charge, target._charge)
assert after == before

This is atomic at the model level: the operation either makes the complete valid change or makes no change. It is not a database transaction and does not address threads or processes. It is still a powerful design rule for ordinary Python objects.

Validation is a gate before mutation. A rejected request returns to the same state.

stateDiagram-v2
  [*] --> Ready
  Ready --> Ready: valid drain or recharge
  Ready --> Ready: invalid request rejected
  Ready --> Empty: drain remaining charge
  Empty --> Ready: recharge
  Ready --> Full: recharge to capacity
  Full --> Ready: drain

Checkpoint: keep failure atomic

4. Use a property when attribute syntax tells the truth

Callers need to read charge and capacity without depending on underscore names. Read-only properties expose those values:

class Battery:
    def __init__(self, capacity, charge):
        if capacity <= 0:
            raise ValueError("capacity must be positive")
        if not 0 <= charge <= capacity:
            raise ValueError("charge must be between 0 and capacity")
        self._capacity = capacity
        self._charge = charge

    @property
    def capacity(self):
        return self._capacity

    @property
    def charge(self):
        return self._charge

    @property
    def percentage(self):
        return self._charge / self._capacity * 100

    @property
    def is_low(self):
        return self.percentage < 20

The caller uses attribute syntax:

battery = Battery(80, 12)

print(battery.charge)
print(battery.percentage)
print(battery.is_low)

percentage and is_low are computed from source state. Storing them as separate fields would create synchronization work: every charge change would also need to update two derived fields.

There is no @charge.setter, so ordinary assignment is rejected:

try:
    battery.charge = 500
except AttributeError as error:
    print(type(error).__name__)

This is useful because the domain operations are drain and recharge, not arbitrary replacement. A property setter would hide which kind of change the caller intends.

Do not manufacture getters and setters

This interface adds ceremony without a rule:

class Label:
    def __init__(self, text):
        self._text = text

    def get_text(self):
        return self._text

    def set_text(self, text):
        self._text = text

If any text is valid and no behavior accompanies assignment, start with self.text = text. Python can later migrate text to a property while keeping the caller’s label.text syntax.

A validating property setter can be appropriate when replacing the value is the honest operation:

class TemperatureSetting:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if not -50 <= value <= 50:
            raise ValueError("celsius must be between -50 and 50")
        self._celsius = value

The constructor deliberately assigns through the property, reusing one validation path:

setting = TemperatureSetting(18)
setting.celsius = 21
assert setting.celsius == 21

Choose by meaning:

Interface Use when
plain attribute direct reading/replacement is valid and needs no rule
read-only property callers need attribute-like derived or protected data
property setter replacing one value through attribute syntax is the honest operation
method the action has domain meaning, arguments, multiple effects, or important failure

5. Offer alternate construction without duplicating initialization

Suppose configuration stores a battery as "60/100": charge first, capacity second. Do not put parsing branches into every caller. A classmethod can provide a named alternate constructor:

class Battery:
    def __init__(self, capacity, charge=0):
        if capacity <= 0:
            raise ValueError("capacity must be positive")
        if not 0 <= charge <= capacity:
            raise ValueError("charge must be between 0 and capacity")
        self._capacity = capacity
        self._charge = charge

    @classmethod
    def from_text(cls, specification):
        """Build a battery from `<charge>/<capacity>` text."""
        charge_text, separator, capacity_text = specification.partition("/")
        if not separator:
            raise ValueError("specification must be <charge>/<capacity>")
        try:
            charge = int(charge_text)
            capacity = int(capacity_text)
        except ValueError as error:
            raise ValueError("charge and capacity must be integers") from error
        return cls(capacity=capacity, charge=charge)

    @property
    def charge(self):
        return self._charge

    @property
    def capacity(self):
        return self._capacity
battery = Battery.from_text("60/100")
assert battery.charge == 60
assert battery.capacity == 100

When Battery.from_text(...) is called, Python binds the receiving class to cls, just as an instance method binds an instance to self. Returning cls(...) reuses the primary constructor and supports subclasses that inherit the alternate constructor.

A module-level parse_battery(text) function could also be clear. Use a classmethod when the operation’s main promise is “construct this class through a named alternate format.” Use a function when parsing has a broader responsibility or produces several possible types.

staticmethod exists for a function stored in a class namespace without receiving self or cls. Do not use it merely because a helper is vaguely related. A module-level function is often easier to find and reuse.

6. Make failures and return values part of the interface

Validation is not complete until callers know how failure appears. Use TypeError when an operation receives the wrong kind of value and ValueError when the kind is acceptable but the value violates the domain range. Do not write a broad except Exception inside the object and silently continue with old or partial state.

For example, this helper separates an integer contract from its range:

def require_non_negative_integer(value, label):
    if not isinstance(value, int) or isinstance(value, bool):
        raise TypeError(f"{label} must be an integer")
    if value < 0:
        raise ValueError(f"{label} cannot be negative")

bool is technically a subclass of int in Python, so the explicit boolean check rejects True as one unit of energy when the domain does not want that surprise. Do not add such checks everywhere by reflex; add them when the public boundary truly promises an integer count.

Return values also belong to the contract. A command-like method can return None, a new state value, or an event record. Pick one meaning and use it consistently. These two methods communicate different questions:

class Meter:
    def __init__(self, value=0):
        self._value = value

    def add(self, amount):
        """Change the meter and return the new value."""
        self._value += amount
        return self._value

    def is_above(self, boundary):
        """Answer a question without changing the meter."""
        return self._value > boundary

Callers should not have to guess whether is_above mutates or whether add returns the old value. Names, docstrings, examples, and assertions make that contract observable.

When a public operation fails, verify three things separately:

  1. the exception type and message identify the violated rule;
  2. protected state matches its before snapshot; and
  3. a later valid operation still succeeds.

The third check catches objects that leave behind a hidden “busy” flag or other partial transition even when visible fields appear unchanged.

7. Build an energy cell with one trustworthy interface

Implement EnergyCell:

class EnergyCell:
    def __init__(self, capacity, charge=0):
        """Create a cell with positive capacity and charge in range."""
        ...

    @classmethod
    def from_percentage(cls, capacity, percentage):
        """Create a cell whose charge is the integer percentage of capacity."""
        ...

    @property
    def capacity(self):
        ...

    @property
    def charge(self):
        ...

    @property
    def percentage(self):
        ...

    def drain(self, amount):
        """Remove available charge and return the remaining charge."""
        ...

    def transfer_to(self, other, amount):
        """Move charge atomically and return both remaining charges."""
        ...

Rules:

  • capacity is a positive integer;
  • charge remains between zero and capacity;
  • percentage must be between zero and 100;
  • from_percentage uses int(capacity * percentage / 100);
  • transfer validates both cells before either changes;
  • public properties are read-only; and
  • every rejected operation leaves all involved state unchanged.

Use these checks:

source = EnergyCell.from_percentage(80, 75)
target = EnergyCell(50, 10)

assert source.capacity == 80
assert source.charge == 60
assert source.percentage == 75.0
assert source.drain(5) == 55
assert source.transfer_to(target, 20) == (35, 30)
assert (source.charge, target.charge) == (35, 30)

before = (source.charge, target.charge)
try:
    source.transfer_to(target, 30)
except ValueError as error:
    assert str(error) == "target lacks capacity"
else:
    raise AssertionError("overfilling transfer should fail")
assert (source.charge, target.charge) == before

try:
    source.charge = 70
except AttributeError:
    pass
else:
    raise AssertionError("charge should be read-only")

Hint 1

Store _capacity and _charge only after validating both. Properties simply return those source fields; percentage computes rather than stores.

Hint 2

Let from_percentage validate the percentage, calculate charge, and call cls(capacity, charge). Do not duplicate the capacity/charge invariant there.

Hint 3

For transfer_to, validate amount, source charge, and other.charge + amount <= other.capacity before subtracting or adding. Commit the two assignments only after every check passes.

Compare a complete energy-cell implementation
class EnergyCell:
    def __init__(self, capacity, charge=0):
        if not isinstance(capacity, int) or isinstance(capacity, bool):
            raise TypeError("capacity must be an integer")
        if capacity <= 0:
            raise ValueError("capacity must be positive")
        if not 0 <= charge <= capacity:
            raise ValueError("charge must be between 0 and capacity")
        self._capacity = capacity
        self._charge = charge

    @classmethod
    def from_percentage(cls, capacity, percentage):
        if not 0 <= percentage <= 100:
            raise ValueError("percentage must be between 0 and 100")
        charge = int(capacity * percentage / 100)
        return cls(capacity, charge)

    @property
    def capacity(self):
        return self._capacity

    @property
    def charge(self):
        return self._charge

    @property
    def percentage(self):
        return self._charge / self._capacity * 100

    def drain(self, amount):
        if amount < 0:
            raise ValueError("amount cannot be negative")
        if amount > self._charge:
            raise ValueError("not enough charge")
        self._charge -= amount
        return self._charge

    def transfer_to(self, other, amount):
        if amount < 0:
            raise ValueError("amount cannot be negative")
        if amount > self._charge:
            raise ValueError("source lacks charge")
        if other.charge + amount > other.capacity:
            raise ValueError("target lacks capacity")
        self._charge -= amount
        other._charge += amount
        return self._charge, other._charge


source = EnergyCell.from_percentage(80, 75)
target = EnergyCell(50, 10)
assert source.drain(5) == 55
assert source.transfer_to(target, 20) == (35, 30)

The transfer reaches into another instance of the same class to commit the second field only after validation. A larger system could give transfer to a separate service that owns both participants. The important property is the same: one operation validates the complete transition before mutation.

Checkpoint: choose the public interface

8. Key points for trustworthy object state

  • State an invariant as a rule that remains true after construction and every public operation—not as validation on only one input path.
  • Validate all reasons an operation can fail before committing its first mutation when the operation must be atomic.
  • Use underscore-prefixed attributes to communicate a non-public convention, not to claim enforced privacy.
  • Prefer intention-revealing methods for domain actions such as draining or transferring energy.
  • Use a plain attribute when unrestricted reading and assignment are honest. Use properties for attribute-like computed, read-only, or validated access.
  • Compute derived state instead of storing a second value that every mutation must synchronize.
  • A classmethod can name an alternate input format and return cls(...), reusing the primary constructor.
  • __init__ initializes valid state and returns None.
  • A failed operation should leave the object—or all participating objects—in a predictable state.

References

Back to top