Skip to content

Latest commit

 

History

History
229 lines (167 loc) · 7.61 KB

File metadata and controls

229 lines (167 loc) · 7.61 KB
paths **/*.py, **/pyproject.toml

Python Domain Types

Domain models, data structures, API contracts, and type definitions.

Core Commands

START with Types

ALWAYS start by modeling your domain with types FIRST.

  • Types ARE the design, not documentation
  • Even 50-line scripts benefit from explicit types over dicts
  • Create your domain vocabulary as types before writing logic

USE Value Objects (Frozen Dataclasses)

from pydantic import Field
from pydantic.dataclasses import dataclass

@dataclass(frozen=True, kw_only=True)
class Measurement:
    value: float = Field(ge=0, description="Non-negative measurement")
    unit: str = Field(min_length=1, description="Unit of measurement")

MUST use:

  • frozen=True for immutable value objects
  • kw_only=True to enforce explicit construction
  • Field(description=...) for API documentation
  • Pydantic dataclasses at boundaries, plain frozen dataclasses internally

NEVER pass dict[str, Any] into core logic.

PARSE Don't Validate

Accept permissive input at boundaries, immediately parse to strict canonical types internally.

# DO: Parse many formats into one canonical type
def _parse_tag(tag: dict[str, str] | tuple[str, str, str] | Tag) -> Tag:
    match tag:
        case {"key": key, "value": value}:
            return Tag(key, value)
        case (key, value, color):
            return Tag(key, value, color=TagColor(color))
        case Tag() as tag:
            return tag

Tags = Annotated[
    set[Tag],
    BeforeValidator(_parse_tags),  # Accept list, set, dict, tuple
    PlainSerializer(_serialize_tags)  # Always output canonical form
]

Core logic MUST work with guaranteed-valid, typed data only.

MAKE Illegal States Unrepresentable

USE discriminated unions with Literal types:

@dataclass(frozen=True, kw_only=True)
class UserMessage:
    content: str
    role: Literal[Role.USER] = Role.USER

@dataclass(frozen=True, kw_only=True)
class AssistantMessage:
    content: str | None = None
    function_call: FunctionCall | None = None
    role: Literal[Role.ASSISTANT] = Role.ASSISTANT

# Discriminated union - type-safe, exhaustive
Message = Annotated[
    UserMessage | AssistantMessage | SystemMessage | FunctionMessage,
    Field(discriminator="role")
]

NEVER use stringly-typed enums or runtime validation for structural variants.

This pattern implements Algebraic Data Types (ADTs) — also called sum types. Where inheritance models "is-a" hierarchies (open, extensible), ADTs model "one-of" variants (closed, exhaustive). Each variant has exactly the fields it needs. The type checker enforces exhaustive handling via pattern matching.

ADTs are native in Rust (enum) and ML-family languages. Python emulates them with union types + frozen dataclasses + match. The concept is the same: data, not behavior; closed set, not open hierarchy.

Literal vs StrEnum

The real trade-off: StrEnum often leads to runtime conversion and enum objects where you often just want wire-compatible data.

Use case Preference Why
Discriminated union tags Literal Wire-compatible, no conversion layer, validation at parse boundary
Config constrained values StrEnum Need list(OutputFormat) for CLI help, runtime enumeration

Literal shines when:

  • The tag exists for schema discrimination and serialization stability
  • You want the data model close to the wire format
  • You're already validating at the parsing layer
  • You don't need to enumerate allowed values at runtime (or you maintain a separate list)

StrEnum shines when:

  • You need a single runtime authority for allowed values (nice errors via ActionType(raw))
  • You benefit from runtime affordances: iteration, .name, grouping helpers, methods
  • CLI tools need to enumerate valid options

Note on comparisons: With StrEnum, ActionType.CLICK == "click" is True (it's a str subclass). You don't have to compare to .value everywhere — but StrEnum still creates enum objects in memory and encourages conversion at boundaries.

Hybrid pattern (Literals + Pydantic v2 discriminated union):

@dataclass(frozen=True, kw_only=True)
class ClickStep:
    action: Literal["click"] = "click"
    selector: str

@dataclass(frozen=True, kw_only=True)
class FillStep:
    action: Literal["fill"] = "fill"
    selector: str
    value: str

Step = Annotated[ClickStep | FillStep, Field(discriminator="action")]

StepsAdapter = TypeAdapter(list[Step])
Steps = Annotated[list[Step], BeforeValidator(StepsAdapter.validate_python)]

Wire format uses strings, model uses Literal, Pydantic enforces allowed values via the discriminated union.

USE Pydantic Field Constraints

At boundaries:

@dataclass(frozen=True, kw_only=True)
class User:
    email: str = Field(pattern=r'^[^@]+@[^@]+\.[^@]+$')
    age: int = Field(ge=0, le=150)

Internally (after parsing):

@dataclass(frozen=True, kw_only=True)
class User:
    email: str
    age: int

USE Pydantic at boundaries. Plain dataclasses internally.

Type Alias Patterns

Simple assignment (Python 3.12+):

# DO: Implicit type alias
FeatureDescriptions = dict[str, FeatureDescription]

# DON'T: Unnecessary TypeAlias import
from typing import TypeAlias
FeatureDescriptions: TypeAlias = dict[str, FeatureDescription]

Generic Type-Safe Functions

USE PEP 695 syntax (Python 3.12+):

type Result[T, E] = Success[T] | Failure[E]

def parse[T](data: str, parser: Callable[[str], T]) -> Result[T, ValueError]:
    try:
        return Success(parser(data))
    except ValueError as e:
        return Failure(e)

Domain-Driven Design Through Types

  • Value Objects: @dataclass(frozen=True) for immutable types
  • Ubiquitous Language: Named types = explicit vocabulary
  • Invariants: Parse-don't-validate + Pydantic constraints

Progression: Define value objects → Add methods → Extract shared behavior → Extract complex logic


Summary

  • DO start with types to model your domain
  • DO use @dataclass(frozen=True, kw_only=True) for value objects
  • DO use Pydantic dataclasses at boundaries for validation
  • DO use plain frozen dataclasses internally after parsing
  • DO use discriminated unions with Literal types for variants
  • DO prefer Literal over StrEnum unless you need runtime enumeration
  • DO use Field() for validation constraints and documentation
  • DO make illegal states unrepresentable through types
  • DO parse permissive input to strict canonical types immediately
  • DON'T pass dict[str, Any] into core logic
  • DON'T use stringly-typed enums (role: str)
  • DON'T use mutable defaults (def foo(items=[]):)
  • NEVER skip validation at boundaries

Related

  • python-composition: When to add methods vs extract functions
  • python-control-flow: Pattern matching on discriminated unions
  • python-modules: Organizing domain types into bounded contexts

Supporting Documentation

  • philosophy.md: Full DDD concepts and rationale
  • examples.md: Extensive value object and type design examples

References