Class Design#

Immutable State Updates#

For immutable domain objects, an update can be represented by constructing a new object instead of mutating the existing one.

dataclasses.replace() provides this pattern for dataclasses:

from dataclasses import dataclass, replace
from decimal import Decimal


@dataclass(frozen=True)
class Position:
    quantity: int
    price: Decimal


position = Position(
    quantity=100,
    price=Decimal("101.25"),
)

updated = replace(
    position,
    quantity=120,
)

assert position.quantity == 100
assert updated.quantity == 120
assert position is not updated

replace() creates a new dataclass instance using the existing field values, with explicitly supplied fields overridden.

This pattern is useful for state transitions:

updated_order = replace(
    order,
    filled_quantity=order.filled_quantity + fill_quantity,
)

The previous state remains unchanged.

Shallow Copy Semantics#

Creating a new outer object does not necessarily create independent copies of objects referenced by its fields.

dataclasses.replace() performs a shallow replacement:

from dataclasses import dataclass, replace


@dataclass
class Order:
    tags: list[str]


order = Order(["client"])
copied = replace(order)

assert order is not copied
assert order.tags is copied.tags

Mutating the shared nested object affects both instances:

copied.tags.append("urgent")

assert order.tags == ["client", "urgent"]

Copy mutable fields explicitly when independent state is required:

copied = replace(
    order,
    tags=order.tags.copy(),
)

Immutable nested values reduce the risk of accidental shared mutation.

Object Validation#

A newly constructed replacement follows the normal dataclass initialization path, including __post_init__().

from dataclasses import dataclass, replace


@dataclass(frozen=True)
class Order:
    quantity: int
    filled_quantity: int

    def __post_init__(self) -> None:
        if self.filled_quantity > self.quantity:
            raise ValueError(
                "filled_quantity cannot exceed quantity"
            )


order = Order(
    quantity=100,
    filled_quantity=20,
)

updated = replace(
    order,
    filled_quantity=50,
)

The replacement is therefore checked against the same invariants as a directly constructed instance.

An invalid state still fails:

replace(
    order,
    filled_quantity=120,
)

# ValueError

Class Member Ordering#

Python does not require one specific ordering for members inside a class.

A useful convention is:

  1. class variables and fields;

  2. initialization methods;

  3. properties;

  4. public methods;

  5. private helper methods.

For example:

from dataclasses import dataclass
from decimal import Decimal


@dataclass(frozen=True)
class Position:
    quantity: int
    price: Decimal

    def __post_init__(self) -> None:
        self._validate_quantity()
        self._validate_price()

    @property
    def notional(self) -> Decimal:
        return self.quantity * self.price

    def has_exposure(self) -> bool:
        return self.quantity != 0

    def _validate_quantity(self) -> None:
        if self.quantity == 0:
            raise ValueError("quantity cannot be zero")

    def _validate_price(self) -> None:
        if self.price <= 0:
            raise ValueError("price must be positive")

The main principle is to expose the public interface before implementation details.

__post_init__() belongs near initialization because it participates in object construction.

Properties are normally part of the public interface:

position.notional

Private methods such as _validate_price() are implementation details and can normally appear after public methods.

For larger classes, locality may be preferable to rigid ordering when a private helper is tightly coupled to one public method.