Application Design and Contracts
Dataclasses and Valid Domain States
Model business concepts so invalid values are rejected at the boundary instead of spreading through the program.
Lesson 1 of 6 in the recommended order · About 25 min (estimate)
On this page
Outcome
Build a frozen dataclass that validates its invariants and exposes an intentional domain operation.
Why it matters
At this level, code is judged by how safely it changes under load, failure, and team ownership. The technique in this lesson makes an important boundary visible enough to test and review.
Concept
A dataclass is most useful when it represents a valid domain fact, not merely a convenient bag of fields. Validate in __post_init__, prefer immutable field values when identity should not drift, and keep parsing outside the model. frozen=True blocks ordinary attribute assignment; it does not make mutable objects stored inside the dataclass deeply immutable.
Read the code
from dataclasses import dataclass
@dataclass(frozen=True)
class Batch:
name: str
records: int
def __post_init__(self):
if not self.name.strip():
raise ValueError("name is required")
if self.records < 0:
raise ValueError("records cannot be negative")
def is_large(self) -> bool:
return self.records >= 1000
print(Batch("daily", 1200).is_large())
Read from the public behavior inward: identify the input boundary, the decision, and the observable result before studying syntax.
Inspect the construction checks after predicting the output
Construction checks: two explicit rules
For the expected str/int inputs, __post_init__ checks: nonblank name, then records ≥ 0.
The checks are code, not automatic enforcement of the type annotations. The first failing check raises an exception.
Both checks pass
Batch("daily", 1200)
Construction returns a Batch
Next operation- is_large() returns True because 1200 ≥ 1000.
The name is checked with strip(), but the code does not replace the stored name with a trimmed value.
Name check fails first
Batch(" ", 1200)
ValueError: name is required
The constructor call raises; it does not return a usable Batch to this caller. The records check is not reached.
Records check fails
Batch("daily", -1)
ValueError: records cannot be negative
The nonblank name passes first, then the negative count is rejected.
Predict the output
Predict the exact output before running the example.
Check your prediction
It prints True. Construction completes only because both invariants hold, and is_large describes a domain decision rather than exposing another conditional to every caller.
Modify the code
Change one valid input into the nearest invalid or overloaded case. Write down which layer should reject it and what the caller should observe.
Review the change
Keep the failure at the narrowest boundary that owns the rule. Preserve a stable return value or exception contract so callers do not need to inspect implementation details.
Debug the bug
A mutable model lets a caller set records to -1 after validation. Freezing the dataclass closes that path and forces changes to create a newly validated value.
Try it yourself
Complete the focused implementation and run its deterministic checks.
Loading this exercise…
Practical challenge (optional)
Add a normalized_name property and a with_records method that returns a new Batch. Explain why the original object must remain unchanged.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- Why put the validation in
__post_init__instead of checking only in a form or route? - What does
frozen=Trueprevent, and what does it not make immutable? - Which two invalid inputs must the exercise reject?
Answers
- Every construction path then enforces the invariant, including callers outside that form or route.
- It prevents ordinary reassignment of dataclass fields. It does not deeply freeze a list, dictionary, or other mutable object stored in a field.
- A blank name after trimming, and a record count below zero.
Sign in to track your progress on this exercise.
Summary and next step
You made the boundary explicit, predicted its behavior, tested a deterministic implementation, and examined its failure mode. Continue to the next lesson to combine this technique with a wider application or production constraint.