Python Domain Modeling
Activation
- Keyword trigger: the request substantively involves
typing.Any, Protocol, ABC, Pydantic v2,BaseModel,BaseSettings, dataclass, Enum/Literal, domain models, invariants,copy.copy,copy.deepcopy, or alias preservation. - Intent trigger: the user asks to design or review Python type interfaces, including ABC versus Protocol ownership, configuration models, input models, domain objects, or fixed choices.
- Disclosure: once when starting applicable work, briefly state:
Using $python-domain-modeling because this task involves .... - Exit: stop applying this skill when the task no longer concerns Python model or interface decisions.
- Do not trigger: a term that appears only in a filename, dependency list, log, or unrelated context is insufficient.
Start From the Data Lifecycle
Before choosing dataclass or Pydantic, trace one real use case:
- Where does data enter from HTTP, configuration, messages, files, or third-party SDKs?
- Which layer converts an untrusted representation into accepted program values?
- Which rules are input-format constraints, and which are domain invariants that must hold throughout object lifetime?
- Who mutates, serializes, persists, or exports the object?
- Should an error be rejected at the boundary, converted into a domain error, or handled by the caller?
Read references/decision-table.md for adjacent choices. Read references/typing-interfaces.md, references/pydantic-v2.md, references/dataclass-guide.md, or references/copy-semantics.md only when that area is relevant. Read references/scenarios.md when multiple choices remain plausible.
Separate Boundary Models From Domain Objects
- Parse external requests, configuration, and messages at boundaries. Pydantic may own parsing, coercion, field errors, and protocol compatibility;
pydantic-settingsmay own configuration sources and precedence. - Internal entities and value objects should express behavior and invariants. Do not introduce runtime validation merely to obtain type hints.
- When boundary and domain semantics differ, use an explicit converter or named factory. Do not hide differences in field names, units, defaults, or nullability behind unexplained dictionary unpacking.
- Reuse one model only when the external contract and internal concept truly share semantics, validation rules, serialization behavior, and change cadence.
Distinguish Construction Rules From Lifetime Invariants
- Use generated dataclass initialization when fields can be assigned directly.
- Put construction-only cross-field validation, normalization, and derived-field calculation in
__post_init__. - Do not rely only on
__post_init__for rules that must survive later state changes. Use named behavior methods, immutable objects, or a setter only when public assignment is appropriate. - Use a read-only property for a value computed on access. Do not move construction logic into a property merely to keep
__init__visually short. - A property does not reduce per-instance memory. Evaluate
slots=Truewhen compact layout matters, and verify with measurement. - Dataclass fields and same-named properties can conflict with generated initialization. Prefer
InitVar, private storage, a named factory, or a regular class when input conversion is required.
Choose Interfaces and Fixed Choices Deliberately
- Avoid
Anywhen the value has a knowable shape. Prefer a concrete type, generic parameter, union, Protocol, TypedDict, or validated boundary model. UseAnydeliberately at truly dynamic/untyped boundaries and narrow it promptly. - Choose ABC or Protocol only after deciding whether nominal inheritance, shared implementation, runtime registration, or structural compatibility is required.
- Do not create an interface merely because two classes currently share method names. Identify the stable capability required by a caller.
- Choose Enum or Literal based on scope, reuse, serialization, and whether members require behavior.
- After introducing Enum, verify JSON, database, CLI, and log values so an internal member name does not accidentally become an external contract.
Implementation and Verification
- Write down the trust boundary and object responsibility before implementation.
- Keep parsing, domain construction, and serialization conversion in identifiable locations.
- Test at least one valid construction, one invalid state, and one mutation path for each lifetime invariant.
- When replacing an existing model, check call signatures, exception types, serialization, equality, hashing, copying, and defaults.
- Use the smallest combination of modeling tools that expresses the actual constraints.
When copying an object graph is part of a model change, define which outer/nested objects and aliases must be shared or reconstructed. Do not treat deepcopy() as "every object becomes new" or pickle as a generic deep-copy substitute.
Required Output
Report only the items relevant to the requested change; do not impose a full review on a small task.
State:
- where data crosses the trust boundary;
- which rules are input constraints versus domain invariants;
- which invariants are construction-only versus lifetime-long;
- why the selected tool is preferable to adjacent choices;
- where boundary, domain, and persistence conversions occur; and
- which invalid states, mutations, and serialization contracts are tested.