Create your own
Lesson illustration

Modeling Domain Values with Dataclasses

Hello again. Last time, you replaced loosely constrained strings with explicit IncidentSeverity and IncidentStatus enums. That gives the incident domain a stable vocabulary, but the information about a single incident is still scattered across individual variables.

In this lesson, you will group those values into focused dataclasses: plain Python domain objects that express incident concepts and rules without being tied to HTTP payloads, Pydantic, SQLAlchemy, or database tables. This distinction will keep the application easier to test and easier to change as FastAPI and PostgreSQL arrive in later modules.


One incident, several representations

A professional service commonly represents the same business concept in more than one form. That is not needless duplication when each representation has a different responsibility.

RepresentationPrimary concernExample
Domain modelBusiness vocabulary, invariants, and behaviorIncident, IncidentTitle
API request/response modelPublic HTTP contract, parsing, validation, serializationA future Pydantic CreateIncidentRequest
Database modelTables, columns, foreign keys, query mappingA future SQLAlchemy incident row

For example, an API client may submit only a title and severity when creating an incident. The internal domain object may generate its own identifier and creation time. A database model will eventually need storage-specific details, such as column definitions and relationships. A response model may deliberately omit internal fields.

Trying to use one class for all of these jobs makes every change cross boundaries unnecessarily:

  • A database migration can reshape application code.
  • An API compatibility decision can distort the domain language.
  • ORM decorators and framework imports can appear in business-rule tests.
  • A field that must not leave the service becomes harder to protect.

The architectural goal is not to eliminate mapping between representations. It is to make that mapping explicit and local to the boundary.

A layered application architecture in which the application layer coordinates domain model objects and repositories, while the repository mediates access to the database layer. The domain model is shown separately from database concerns.

The domain model should be readable even if the application is temporarily backed by an in-memory repository rather than PostgreSQL. This is particularly useful when testing rules: a test of whether an incident title is valid should not need an HTTP request, a running database, or cloud infrastructure.

It Seems I No Longer Use Python’s Dataclasses

Watch “It Seems I No Longer Use Python’s Dataclasses” by ArjanCodes for a compact example of a dataclass used as an internal object while separate Pydantic models handle an API boundary.

Watch separate models. Focus on the distinction between the internal Book dataclass, which generates an ID, and the separate create and response schemas. Transfer the structural idea to incidents; do not treat the specific book fields as a template.


What @dataclass provides

A normal class can represent an incident perfectly well, but a data-focused class often requires repetitive methods:

  • an initializer to assign fields;
  • a useful representation for logs and debugging;
  • equality for values that should compare by their contents;
  • carefully maintained defaults as fields are added.

The standard-library @dataclass decorator generates much of this correctly from annotated fields. It does not turn Python into a runtime-validated language: annotations still express a contract for readers and static type checkers. You add runtime checks only where a domain invariant needs them.

Python dataclasses will save you HOURS, also featuring attrs

In “Python dataclasses will save you HOURS, also featuring attrs,” mCoding demonstrates the boilerplate dataclasses remove and the options that matter most for a domain model.

Watch generated methods for the generated initializer, representation, and equality behavior. Then watch key options for frozen=True and the caution around generated ordering. Finish with field defaults, paying special attention to why mutable fields need default_factory.

For a small immutable domain value, the definition is pleasantly direct:

from dataclasses import dataclass


@dataclass(frozen=True)
class IncidentTitle:
    value: str

Python generates an initializer equivalent in intent to:

def __init__(self, value: str) -> None:
    self.value = value

It also generates a readable representation and equality based on the field:

title = IncidentTitle(value="Checkout unavailable")

assert title == IncidentTitle(value="Checkout unavailable")
print(title)
# IncidentTitle(value='Checkout unavailable')

Two titles with the same text represent the same value. They do not need separately assigned identities.

The frozen=True option prevents ordinary reassignment after construction:

title = IncidentTitle(value="Checkout unavailable")
title.value = "Database unavailable"  # raises FrozenInstanceError

That makes a value object safer to share: code cannot silently alter it after an incident has been created. A frozen dataclass with comparable fields is also hashable by default, so it can be used in a set or as a dictionary key.

Read the selected parts of the official Python documentation to anchor these behaviors in the standard-library contract.

dataclasses — Data Classes

Read the official Python documentation for the behavior behind field(), post-initialization validation, frozen instances, and safe mutable defaults.

In the dataclasses.field() subsection, read the explanation and example of default_factory; it is the mechanism used for per-instance mutable values and generated values. In the “Post-init processing” subsection, read post initialization to see when __post_init__ runs. Then read the full “Frozen instances” subsection, beginning the immutability explanation. Finally, in “Mutable default values,” read the shared-default problem and its remedy.

A frozen dataclass is not deeply immutable. If it holds a mutable list, dictionary, or set, code can still mutate that contained object. For frozen value objects, favor immutable field types such as str, int, UUID, tuples, and frozenset.

Also avoid order=True by default. It would compare fields in their declaration order, which is rarely a meaningful business ordering. An incident title alphabetically preceding another title is not an operational priority rule.


Model a value object with a local invariant

Let us place the domain types in a dedicated module:

src/incident_api/domain_models.py

Start with the existing enums from incident_enums.py, then define a title as a value object.

from dataclasses import dataclass
from uuid import UUID

from incident_api.incident_enums import IncidentSeverity, IncidentStatus


@dataclass(frozen=True, kw_only=True)
class IncidentTitle:
    value: str

    def __post_init__(self) -> None:
        normalized = self.value.strip()

        if not normalized:
            raise ValueError("Incident title must not be blank")

        if len(normalized) > 200:
            raise ValueError("Incident title must contain at most 200 characters")

        object.__setattr__(self, "value", normalized)

This class makes three domain decisions explicit:

  1. An incident title is not just an arbitrary str; it is a named concept.
  2. Blank titles and excessively long titles cannot enter the domain.
  3. Surrounding whitespace is normalized once, when the object is created.

__post_init__() runs immediately after the generated initializer. Since this dataclass is frozen, its normal attribute assignment is locked after initialization. object.__setattr__() is therefore used narrowly within __post_init__ to store the canonical title. It should not become a general escape hatch for later mutation.

kw_only=True makes construction explicit:

title = IncidentTitle(value="  Checkout unavailable  ")

assert title.value == "Checkout unavailable"

Keyword-only construction is helpful as models grow. It prevents accidental positional calls where two similarly shaped values are swapped. It requires Python 3.10 or later, which is a sensible baseline for a new FastAPI project.

Notice what this class does not import:

  • no fastapi;
  • no Pydantic BaseModel;
  • no SQLAlchemy declarative base;
  • no request, response, session, or table definitions.

It is valid Python describing the business concept alone.


Model an incident entity separately from its values

An entity differs from a value object because it has a persistent identity. An incident remains the same incident when its status changes from NEW to ACKNOWLEDGED, or when someone is assigned to it.

An Incident can therefore be a mutable dataclass, while IncidentTitle remains frozen.

from dataclasses import dataclass, field
from datetime import datetime, timezone
from uuid import UUID, uuid4

from incident_api.incident_enums import IncidentSeverity, IncidentStatus


def utc_now() -> datetime:
    return datetime.now(timezone.utc)


@dataclass(kw_only=True, eq=False)
class Incident:
    title: IncidentTitle
    severity: IncidentSeverity
    incident_id: UUID = field(default_factory=uuid4)
    status: IncidentStatus = IncidentStatus.NEW
    assignee_id: UUID | None = None
    tags: set[str] = field(default_factory=set)
    created_at: datetime = field(default_factory=utc_now)

Construct it as a domain object:

incident = Incident(
    title=IncidentTitle(value="Checkout unavailable"),
    severity=IncidentSeverity.CRITICAL,
)

assert incident.status is IncidentStatus.NEW
assert incident.assignee_id is None

Several design choices matter here.

Generated values belong in default_factory

uuid4 and utc_now are functions that should run separately for each new incident. Passing them to default_factory gives each instance an independent value.

first = Incident(
    title=IncidentTitle(value="Checkout unavailable"),
    severity=IncidentSeverity.HIGH,
)

second = Incident(
    title=IncidentTitle(value="Search latency elevated"),
    severity=IncidentSeverity.MEDIUM,
)

assert first.incident_id != second.incident_id
assert first.created_at <= second.created_at

The same rule protects tags. This is wrong:

# Do not do this
tags: set[str] = set()

A mutable default could be shared between instances. The correct definition is:

tags: set[str] = field(default_factory=set)

Each incident now receives its own empty set. Adding a tag to one incident cannot alter another incident’s tags.

Why eq=False for the entity?

Dataclasses generate equality from all fields by default. That is ideal for IncidentTitle, where all relevant identity is in value.

For an incident entity, equality based on every field would be misleading. A severity or status change should not make it become a different incident. Setting eq=False prevents the dataclass from claiming that complete current state defines entity identity.

For now, compare incident identifiers explicitly where that is the real question:

same_incident = first.incident_id == second.incident_id

A larger application may later adopt a consistent entity-equality policy based on incident_id. The important decision at this stage is not to inherit value-object semantics accidentally.

Mutability should match the concept

Making every dataclass frozen is not a universal rule. This split is intentional:

TypeMutation policyReason
IncidentTitleFrozenA title value is replaced with a new value rather than edited in place.
IncidentMutableIts operational state, assignee, and tags can legitimately change.
API request modelDetermined by API frameworkIt represents parsed external input, not domain identity.
Database modelDetermined by ORM needsIt represents persistence and query behavior.

Later, status changes will be protected by explicit transition rules. For now, avoid adding speculative lifecycle methods merely because status exists. The current model establishes the vocabulary and shape; it does not yet define every allowed operation.


Keep boundary mapping explicit

Eventually, a FastAPI endpoint will receive JSON, Pydantic will parse and validate it, and an application service will create domain objects. Later still, a repository will persist those objects with SQLAlchemy.

At each boundary, conversion is intentional:

BoundaryIncoming formInternal result
HTTP requestJSON primitives such as "critical"Validated API request model
Application serviceRequest-model valuesIncidentTitle, IncidentSeverity, and Incident
RepositoryDomain IncidentDatabase persistence representation
HTTP responseDomain resultDeliberately shaped response model

Do not use dataclasses.asdict() as a shortcut for an API response or database row. It can be useful for debugging or controlled internal transformations, but it does not define a public contract, decide which fields are sensitive, or replace a persistence mapping.

Keeping the mapper near the boundary lets each layer change for its own reasons. A future API response can expose title as a string without forcing the domain to abandon IncidentTitle; a future database can store a UUID in its preferred database type without adding SQLAlchemy-specific types to this module.


Implementation checkpoint

Add the two dataclasses to domain_models.py and verify these points in review:

  • IncidentTitle is a frozen value object with a narrow, meaningful invariant.
  • Incident uses IncidentSeverity and IncidentStatus, never raw strings.
  • Each Incident receives independent IDs, timestamps, and tag sets through default_factory.
  • The domain module imports no FastAPI, Pydantic, or SQLAlchemy types.
  • The mutable Incident does not receive automatic whole-state equality.
  • Generated representations do not contain fields you would consider secrets. If a future domain object contains a token or credential, use field(repr=False) and avoid logging the object indiscriminately.

Key takeaways

Dataclasses provide a concise, standard-library way to model internal domain concepts with generated initialization, representation, and selected comparison behavior.

  • Use frozen dataclasses for small value objects such as IncidentTitle.
  • Use __post_init__() for focused invariants that must hold whenever a domain object is created.
  • Use field(default_factory=...) for values that must be newly created per instance, especially mutable collections, UUIDs, and timestamps.
  • Make entities mutable only when their domain state genuinely changes, and avoid treating every current field as their identity.
  • Keep dataclasses independent from Pydantic API schemas and SQLAlchemy database mappings; transform between those forms at explicit boundaries.

Next, you will organize packages and imports so foundational modules such as enums and domain models remain easy to use without circular dependencies or import-time side effects.

Can't find a good explanation? Sign up and we'll make it for you

Sign up