| paths | **/*.py, **/pyproject.toml |
|---|
Domain models, data structures, API contracts, and type definitions.
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
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=Truefor immutable value objectskw_only=Trueto enforce explicit constructionField(description=...)for API documentation- Pydantic dataclasses at boundaries, plain frozen dataclasses internally
NEVER pass dict[str, Any] into core logic.
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.
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.
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.
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: intUSE Pydantic at boundaries. Plain dataclasses internally.
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]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)- 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
- 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
LiteraloverStrEnumunless 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
- 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
philosophy.md: Full DDD concepts and rationaleexamples.md: Extensive value object and type design examples
- Parse, Don't Validate - Original concept
- Parse, Don't Validate (Python Edition) - Python-specific patterns
- Cosmic Python: Domain Modeling - Domain-driven design in Python
- Exploring Four Languages - Type system thinking