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:
class variables and fields;
initialization methods;
properties;
public methods;
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.