Skip to main content
Learning Center
Python Programming

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.

After successful construction, frozen=True blocks ordinary field reassignment; it is not deep immutability. Other dataclasses that hold mutable lists can still have those lists mutated. This Batch example uses string and integer fields; annotations alone do not reject every wrong type.

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

  1. Why put the validation in __post_init__ instead of checking only in a form or route?
  2. What does frozen=True prevent, and what does it not make immutable?
  3. Which two invalid inputs must the exercise reject?
Answers
  1. Every construction path then enforces the invariant, including callers outside that form or route.
  2. It prevents ordinary reassignment of dataclass fields. It does not deeply freeze a list, dictionary, or other mutable object stored in a field.
  3. 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.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.