Package Tutorial 1 - Core (Deep)¶
Milestone 2 · Package Tutorials · Tutorial 1 of 13
The Fundamentals suite introduced one idea per package. The Package
Tutorials go a layer deeper: the full public API of each package, how it
integrates with the layers below, and the extension point you implement
to make it your own. This first tutorial covers mineproductivity.core - the
substrate every other package is built on. It is authored to
Package Tutorial Template v1.0 and is the reference implementation for the
Package Tutorial Implementation Standard
that governs Tutorials 2-13.
Objective¶
Master the working surface of mineproductivity.core: the identity/value split
at depth, the four extension contracts (repository, serializer, validator,
specification), the two constructor patterns (factory, builder), the
railway-oriented error types (Result, Maybe, the exception hierarchy), and -
the payoff - implementing the BaseRepository contract yourself for a
production backend.
Everything here is a public API (mineproductivity.core.__all__, 38 symbols).
Every symbol is accounted for under the coverage convention (§5): the working
majority gets deep coverage (walkthrough + executed example); the cross-cutting
remainder gets reference coverage (a one-line "what/when" plus the API-reference
pointer). No private internals are taught except where you implement an abstract
method, which is the contract, not an internal.
Prerequisites¶
- Fundamentals L02 - Entities: why HT-214 is still HT-214 after a rebuild (identity equality).
- Fundamentals L03 - Value objects: why an ore grade has no identity and cannot exist invalid (structural equality).
This tutorial builds on those two; it does not repeat them. If "entity vs value object" is not yet second nature, read them first.
Running the examples¶
Every code block below is executed and its output pasted verbatim. The six reusable scripts live in the repository:
1. Why this package exists¶
Every package in MineProductivity - events, kpis, analytics, agents, all
of them - needs the same handful of primitives: a way to say "these two objects
are the same thing", a way to store and look objects up, a way to report failure
without guessing, a way to validate. If each package invented its own, the
platform would have fourteen entity models and fourteen error styles, and no two
of them would compose.
core exists so there is exactly one of each: one identity model
(BaseEntity), one immutable-value model (BaseValueObject), one repository
contract (BaseRepository), one error hierarchy (MineProductivityError), one
explicit-failure type (Result). It is the vocabulary the rest of the platform
is written in.
Two constraints define it:
- No domain knowledge.
coredoes not know what a tonne, a truck, or a shift is. The example scripts useSensor,Coordinates,Shift- but those are defined in the examples, not incore. This is whyontology(the mining vocabulary) sits abovecore, not inside it. - No I/O.
coreperforms no file, network, or database access. It defines theBaseRepositoryandBaseConfigurationcontracts; the actual SQL driver or config loader lives in a higher package that depends oncore.
2. Architectural role¶
core is the root of the dependency chain. The locked layer order is:
core ─► ontology ─► events ─► kpis ─► analytics ─► decision
─► digital_twin ─► simulation ─► optimization ─► agents ─► visualization
Everything points down to core; core points at nothing above it. That is
the Dependency Inversion Principle in physical form: core publishes
abstract contracts (BaseRepository, BaseSerializer, BaseValidator), and the
packages that need concrete behaviour implement those contracts - never the
reverse. A production SQL repository imports core.BaseRepository; core never
imports the SQL repository.
This is also why core is the correct first Package Tutorial: you cannot deeply
understand any layer above until you can read its core foundations fluently.
3. Integration with adjacent layers¶
Not applicable - core is the foundational layer
Every other Package Tutorial (2-13) uses this section to show how the package
consumes the governed outputs of the layer below without re-deriving them -
the platform's central discipline (e.g. analytics reads KPIResults and
never recomputes tonnes ÷ hours). core has no lower dependency: it is the
bottom of the stack and imports nothing from the platform. There is therefore
no downward integration to describe here.
What integrates upward: every package in §2's chain builds on the primitives
below. When you meet EventEnvelope (events), KPIMetadata (kpis), or
TwinState (digital_twin), you are looking at a BaseValueObject; every
*Repository is a BaseRepository; every package's errors derive from
MineProductivityError. Fluency here is what makes those readable.
4. Package structure¶
core is one Python package of small, single-responsibility modules. The public
surface is deliberately flat - you import everything from mineproductivity.core,
never from a submodule:
| Module | Public symbols | Concern |
|---|---|---|
entity |
BaseEntity |
identity-based equality |
value_object |
BaseValueObject |
structural equality, immutability |
identifier |
BaseIdentifier, UUIDIdentifier |
typed identity wrappers |
metadata |
BaseMetadata |
descriptive name/tags/attributes |
versioning |
BaseVersionedObject |
monotonic version for optimistic concurrency |
repository |
BaseRepository, InMemoryRepository |
storage contract + reference impl |
specification |
BaseSpecification, And/Or/NotSpecification, PredicateSpecification |
composable predicates |
factory |
BaseFactory |
one-shot construction |
builder |
BaseBuilder |
fluent step-by-step construction |
validator |
BaseValidator, CompositeValidator, ValidationResult |
validation-as-object |
serialization |
BaseSerializer, DataclassSerializer, to_dict, SupportsToDict, SupportsFromDict |
object ⇄ plain dict |
result |
Result |
success-or-error without raising |
maybe |
Maybe |
value-or-absence without None |
configuration |
BaseConfiguration |
immutable validated settings shape |
service |
BaseService |
marker for stateless domain services |
exceptions |
MineProductivityError + 6 subclasses |
one error hierarchy |
typing |
Comparable, Identifiable, JSONValue, JSONPrimitive |
shared structural types |
5. Public APIs¶
All 38 exports, grouped by role. This is the entire surface - if it is not here, it is not public. Each symbol carries its coverage convention tag:
- [deep] - taught below with a walkthrough and an executed example.
- [ref] - reference coverage: a one-line "what/when" (see "Reference coverage" at the end of this section) plus the API reference.
- Domain building blocks - all [deep]
BaseEntity,BaseValueObject,BaseIdentifier,UUIDIdentifier,BaseMetadata,BaseVersionedObject- Storage & querying - all [deep]
BaseRepository,InMemoryRepository,BaseSpecification,AndSpecification,OrSpecification,NotSpecification,PredicateSpecification- Construction - all [deep]
BaseFactory,BaseBuilder- Validation - all [deep]
BaseValidator,CompositeValidator,ValidationResult- Serialization - all [deep]
BaseSerializer,DataclassSerializer,to_dict,SupportsToDict,SupportsFromDict
Error handling - Result, Maybe, MineProductivityError, ValidationError,
NotFoundError, DuplicateError, SerializationError are [deep];
ConfigurationError, BuilderError are [ref]
- Cross-cutting - all [ref]
BaseConfiguration,BaseService,Comparable,Identifiable,JSONValue,JSONPrimitive
Reference coverage¶
The eight [ref] symbols are real public API, used mostly through other packages rather than directly in a first Core tutorial. Each in one line:
| Symbol | What / when to reach for it |
|---|---|
BaseConfiguration |
Immutable, self-validating settings container; build with from_mapping(...). Sourcing (env/file) belongs to a higher config package - core does no I/O. |
BaseService |
Marker base for a stateless operation that spans several entities/repositories (a DDD "domain service"); declares no abstract methods. |
Comparable |
@runtime_checkable structural type for anything supporting <; lets a generic sort/rank utility accept core types and built-ins alike. |
Identifiable |
Structural type for anything exposing an id; prefer it over importing BaseEntity when you only need "has an id", not "is an entity". |
JSONPrimitive |
Type alias str \| int \| float \| bool \| None - one JSON scalar. |
JSONValue |
Recursive alias: a JSONPrimitive, or a list/dict of JSONValue. The vocabulary serialization signatures speak. |
ConfigurationError |
Raised when configuration data is missing/malformed (e.g. BaseConfiguration.from_mapping on a bad mapping). |
BuilderError |
For your BaseBuilder to raise when asked to build() from incomplete/inconsistent state. |
6. Conceptual model¶
Four ideas explain the whole package.
A. Two kinds of "sameness". A domain concept either has an identity that
outlives its state (an entity - HT-214 is the same truck before and after a
refuel) or is fully defined by its values (a value object - the coordinate
(40.7, -74.0) is that coordinate, nothing else). BaseEntity gives you the
first; BaseValueObject the second. Everything else in core is built from
these two.
B. Contracts live here; implementations live above. BaseRepository,
BaseSerializer, BaseValidator, and BaseSpecification are abstract. core
ships one reference implementation where a runnable default is genuinely useful
(InMemoryRepository, DataclassSerializer, CompositeValidator) and nothing
else. The real backends are your extension points (§13).
C. Failure is a value, not (only) an exception. Result[T] and Maybe[T]
let a function return "this failed" or "there was nothing" instead of raising,
so the caller must handle it. When raising is right, every framework exception
derives from MineProductivityError, so one except catches them all.
D. Composition over inheritance. You extend behaviour by combining objects,
not subclassing: specifications combine with &, |, ~; validators combine
via CompositeValidator; serialization is a separate object, not a method bolted
onto the domain type.
7. Real mining examples¶
core is domain-free, so a Package Tutorial has to supply the mining domain.
Throughout the walkthroughs the mapping is:
core abstraction |
Mining instance used below |
|---|---|
BaseValueObject |
Coordinates (a pit location), Reading (a sensor sample) |
BaseEntity[str] |
Asset, Sensor, HaulTruck |
BaseIdentifier |
a TruckId wrapping a string, so it can't be swapped for a ShiftId |
BaseSpecification |
IsLowBattery (a sensor below 20 %) |
BaseFactory / BaseBuilder |
a Shift constructed two ways |
BaseValidator |
asset-code rules (non-empty, ≤ 8 chars, alphanumeric) |
BaseRepository (extension) |
a status-indexed FleetRepository of haul trucks |
8. Step-by-step walkthroughs¶
8.1 Identity vs value, at depth¶
Beyond L02/L03, two subtleties matter.
The eq=False rule is not optional - and not inherited. BaseEntity
defines identity-based __eq__/__hash__. But Python regenerates __eq__ for a
dataclass unless the subclass itself declares eq=False. Inheriting from
BaseEntity is not enough - every concrete entity must repeat it:
@dataclass(frozen=True, slots=True, eq=False) # eq=False is mandatory
class Asset(BaseEntity[str]):
name: str
location: Coordinates
Omit eq=False and your entity silently reverts to field-based equality -
two different trucks with the same fields would compare equal, and identity is
lost. This is the single most common core mistake (§10).
Normalize, then validate - automatically. Every BaseValueObject runs two
hooks after construction, in order: _normalize() (coerce/defensively-copy
mutable inputs - because the object is frozen, you assign via
object.__setattr__) then validate() (raise on an invariant breach). You never
call them; __post_init__ does. replace() re-runs both, so a "modified" copy
can never skip validation.
Running 01_entity_and_value_object.py:
--- BaseValueObject: equality by value ---
a == b: True (same coordinates, different instances)
hash(a) == hash(b): True
a.replace(longitude=-73.9) -> Coordinates(latitude=40.7128, longitude=-73.9)
original a unchanged: Coordinates(latitude=40.7128, longitude=-74.006)
invalid coordinates rejected: latitude must be between -90 and 90
--- BaseEntity: equality by identity ---
original == relocated: True (same id, different location)
original == different_asset: False (different id)
repr(original): Asset(id='A-1', name='Crusher #1', location=Coordinates(latitude=40.7128, longitude=-74.006))
Note line 3: replace() returns a new object; original a is unchanged -
immutability holds. And original == relocated is True despite a different
location, because identity, not state, defines an entity's equality.
8.2 Typed identifiers, metadata, versioning¶
Passing raw str ids around invites bugs - nothing stops you handing a truck id
to a function expecting a shift id. BaseIdentifier wraps a raw value in a typed
value object so the type checker catches the mix-up:
@dataclass(frozen=True, slots=True)
class TruckId(BaseIdentifier[str]):
pass
TruckId("HT-214") == TruckId("HT-214") # True - identifiers are value objects
str(TruckId("HT-214")) # 'HT-214'
UUIDIdentifier is the ready-made case: UUIDIdentifier.generate() for a fresh
v4 UUID, UUIDIdentifier.from_string(s) to parse one back.
BaseMetadata is the domain-agnostic name / description / tags /
attributes container that higher packages compose into their own metadata
(this is the platform's "metadata-first" habit). BaseVersionedObject adds a
monotonic version and a next_version() that returns an incremented copy - the
building block for optimistic-concurrency checks. Both are value objects: you
never mutate, you produce a new instance.
8.3 The repository contract and specification algebra¶
BaseRepository[TEntity, TId] is a five-method collection contract: add,
get, find, remove, list, plus __contains__ for free. get raises
NotFoundError; find returns a Maybe instead - the caller chooses whether a
miss is exceptional. add raises DuplicateError on an id clash.
Filtering is where the Specification pattern pays off. A BaseSpecification
is one business rule as an object, and rules compose with operators:
low_or_flagged = IsLowBattery() | IsFlagged() # OrSpecification
healthy = ~IsLowBattery() # NotSpecification
critical = IsLowBattery() & IsInPit() # AndSpecification
repo.list(critical) # filtered query
For a one-off rule that does not deserve a class, wrap a lambda in
PredicateSpecification(lambda s: s.battery_percent < 20). Running
02_repository.py:
total sensors: 3
low battery sensors: ['Vibration B', 'Temperature C']
find('S-1').is_some: True, name: Vibration A
find('does-not-exist') name-or-default: <not found>
adding a duplicate id raised: Entity with id 'S-1' already exists.
getting a missing id raised: No entity found with id 'does-not-exist'.
after remove('S-3'): ['S-1', 'S-2']
Line 4 is the pattern to internalize: find(...).map(lambda s: s.name).unwrap_or("<not found>")
handles the miss without a single if or try - the Maybe carries the
absence and the combinators thread the happy path.
8.4 Factory vs builder - and their Result variants¶
Both encapsulate construction; choose by shape. A factory builds in one shot
from keyword arguments (BaseFactory.create(**kwargs)). A builder builds
step-by-step with fluent chaining (BaseBuilder), which reads better when there
are many optional steps. Both offer an exception-free variant -
create_result(...) / build_result() - that returns a Result instead of
raising, capturing an invalid-input error as an Err. Running
03_factory_and_builder.py:
--- BaseFactory ---
factory.create(...): Shift(crew='Alpha', start_hour=6, duration_hours=10)
invalid input via create_result -> is_err: True, error: start_hour must be in [0, 24)
--- BaseBuilder (fluent chaining) ---
builder chain -> Shift(crew='Night Crew', start_hour=18, duration_hours=12)
invalid build via build_result -> is_err: True
8.5 Validation as a separate object¶
A value object validates its own invariants in validate(). But contextual
rules - ones that depend on external state or vary by use case - belong in a
standalone BaseValidator, which returns a ValidationResult (never raises by
itself). CompositeValidator(*validators) merges several rules, accumulating
all errors rather than stopping at the first, and ValidationResult
.raise_if_invalid() bridges to an exception-based call site. Running
04_validation.py:
'TRK001': valid
'' (empty): invalid -> ['must not be empty', 'must contain only letters and digits']
'this-code-is-way-too-long-and-has-dashes': invalid -> ['must be at most 8 characters', 'must contain only letters and digits']
--- raise_if_invalid() for exception-based call sites ---
raised: ValidationError('Validation failed with 2 error(s).', details={'errors': ('must be at most 8 characters', 'must contain only letters and digits')})
Two errors on one candidate - that accumulation is the point. An operator fixing an asset code sees every problem at once, not one per attempt.
8.6 Serialization, kept out of the domain type¶
core keeps "how to turn this into a dict" off the domain object.
DataclassSerializer(SomeType) serializes/deserializes any dataclass;
to_dict(obj) is the best-effort helper that prefers an object's own to_dict()
(the SupportsToDict protocol) and otherwise recurses over dataclass fields.
Deserialization failure raises SerializationError. Running
05_serialization.py:
--- DataclassSerializer ---
serialize(reading) -> {'sensor_id': 'S-1', 'value': 42.5, 'unit': 'celsius'}
as JSON -> {"sensor_id": "S-1", "value": 42.5, "unit": "celsius"}
deserialize(payload) -> Reading(sensor_id='S-1', value=42.5, unit='celsius')
restored == reading: True
8.7 Result and Maybe combinators¶
The two railway types share a chainable surface. Result[T]: ok / err,
is_ok / is_err, value / error / unwrap / unwrap_or / unwrap_or_else,
and the combinators map, map_err, and_then. Maybe[T]: some / nothing,
is_some / is_nothing, unwrap / unwrap_or / unwrap_or_else, map,
and_then, filter, and to_result(error) to cross over. Running
06_result_and_maybe.py:
--- Result[T]: success/failure without exceptions ---
parse_percentage('42.5') -> ok(42.5)
parse_percentage('not-a-number') -> err('not-a-number' is not a number)
parse_percentage('150') -> err(150.0 is out of range [0, 100])
chained map/and_then -> 800.0
--- Maybe[T]: explicit optionality without None ambiguity ---
find_first_over(readings, 50) -> Maybe.some(88.2)
unwrap_or(default) -> 88.2
find_first_over(readings, 1000) -> Maybe.nothing()
Maybe.to_result(...) -> is_err: True, error: no reading exceeded threshold
chained map/and_then -> 800.0 is a whole validated pipeline
(parse "80" → /100 → *1000) with no exceptions and no intermediate if.
9. Repository example reuse¶
This tutorial reuses the six core example scripts rather than inventing new
ones - they are executable documentation, kept correct because they are run. Each
was executed for this tutorial (exit 0) and its output pasted above.
| Script | Public API it exercises | Walkthrough |
|---|---|---|
01_entity_and_value_object.py |
BaseEntity, BaseValueObject, replace |
§8.1 |
02_repository.py |
InMemoryRepository, BaseSpecification, Maybe, DuplicateError, NotFoundError |
§8.3 |
03_factory_and_builder.py |
BaseFactory, BaseBuilder, Result variants |
§8.4 |
04_validation.py |
BaseValidator, CompositeValidator, ValidationResult |
§8.5 |
05_serialization.py |
DataclassSerializer, to_dict |
§8.6 |
06_result_and_maybe.py |
Result, Maybe combinators |
§8.7 |
10. Common mistakes¶
| Mistake | What happens | Do this instead |
|---|---|---|
Omitting eq=False on a BaseEntity subclass |
Identity silently reverts to field equality; two different trucks compare equal | Always @dataclass(frozen=True, slots=True, eq=False) |
| Using an entity where a value object belongs (or vice-versa) | Wrong equality semantics; e.g. two equal ore grades treated as distinct | Has an identity that outlives state → entity; else → value object |
Assigning in _normalize with plain self.x = ... |
FrozenInstanceError - the object is frozen |
object.__setattr__(self, "x", value) |
Unwrapping a Result/Maybe without checking |
unwrap() on an Err/nothing raises |
Check is_ok/is_some, or use unwrap_or / map / and_then |
Reading .error on an Ok (or .value on an Err) |
ValueError / the stored exception is raised |
Guard with is_err / is_ok first |
| Deserializing a mapping with the wrong keys | SerializationError wrapping the TypeError |
Serialize/deserialize the same dataclass shape |
| Mutating a "changed" value object in place | Impossible (frozen) or, if forced, skips validation | replace(**changes) - it re-runs _normalize+validate |
Importing from a submodule (core.entity) |
Brittle to internal moves | Import from mineproductivity.core |
11. Best practices¶
- Declare the identity type.
BaseEntity[str]or, better, aBaseIdentifiersubclass - it lets the type checker catch id mix-ups. - Put invariants in
validate(), contextual rules in aBaseValidator. The object guards what is always true of it; the validator guards what is true in a use case. - Return
Result/Maybefrom fallible/optional operations you expect callers to handle in the normal course; reserve raising for genuinely exceptional paths. - Catch broadly with
MineProductivityError, narrowly with a subclass - the hierarchy supports both from onetry. - Filter repositories with specifications, and compose them (
&,|,~) instead of writing bespoke query methods. - Keep serialization in a
BaseSerializer, not on the domain type.
12. Performance considerations¶
frozen=True, slots=Trueis a performance choice, not just a safety one.slotsremoves the per-instance__dict__, cutting memory and speeding attribute access - it matters when you hold millions of readings or events.InMemoryRepository.list(spec)is O(n) - it scans and applies the specification to every stored entity. It is a reference implementation for tests and examples; a production backend should push the predicate down to an indexed query (see §13, where theFleetRepositorykeeps a status index for an O(1) lookup the base scan cannot match).- Specification combinators short-circuit -
Andstops at the firstFalse,Orat the firstTrue- so order the cheap, selective rule first. Result/Maybeallocate a small frozen object per step. In a tight inner loop over huge sequences, a plaintry/formay be leaner; at API boundaries the clarity is worth the allocation.- Entity hashing is
hash((type, id))- cheap and stable, so entities are fine as dict keys and set members.
13. Extension points¶
core's primary extension point is the one the architecture calls out:
implement the BaseRepository[TEntity, TId] contract for a real backend.
You provide the five methods; every layer above keeps working, because it depends
on the contract, not your implementation (Dependency Inversion, made concrete).
The example below is a status-indexed in-memory fleet backend. It implements the
full contract and adds a domain query (by_status) the base contract does not
define - extension, not modification. It was executed, and passes
ruff / ruff format --check / mypy --strict:
from collections.abc import Sequence
from dataclasses import dataclass
from mineproductivity.core import (
BaseEntity, BaseRepository, BaseSpecification,
DuplicateError, Maybe, NotFoundError,
)
@dataclass(frozen=True, slots=True, eq=False)
class HaulTruck(BaseEntity[str]):
model: str
status: str # "hauling" | "queued" | "down"
class FleetRepository(BaseRepository[HaulTruck, str]):
"""Implements the contract, plus a secondary index by status."""
def __init__(self) -> None:
self._by_id: dict[str, HaulTruck] = {}
self._ids_by_status: dict[str, set[str]] = {}
def add(self, entity: HaulTruck) -> None:
if entity.id in self._by_id:
raise DuplicateError(f"Truck {entity.id!r} already exists.")
self._by_id[entity.id] = entity
self._ids_by_status.setdefault(entity.status, set()).add(entity.id)
def get(self, entity_id: str) -> HaulTruck:
try:
return self._by_id[entity_id]
except KeyError as exc:
raise NotFoundError(f"No truck with id {entity_id!r}.") from exc
def find(self, entity_id: str) -> Maybe[HaulTruck]:
truck = self._by_id.get(entity_id)
return Maybe.nothing() if truck is None else Maybe.some(truck)
def remove(self, entity_id: str) -> None:
try:
truck = self._by_id.pop(entity_id)
except KeyError as exc:
raise NotFoundError(f"No truck with id {entity_id!r}.") from exc
self._ids_by_status[truck.status].discard(entity_id)
def list(
self, specification: BaseSpecification[HaulTruck] | None = None
) -> Sequence[HaulTruck]:
trucks = list(self._by_id.values())
if specification is None:
return trucks
return [t for t in trucks if specification.is_satisfied_by(t)]
def by_status(self, status: str) -> Sequence[HaulTruck]: # domain extension
return [self._by_id[i] for i in self._ids_by_status.get(status, set())]
Exercising it:
fleet size (inherited list()): 3
down trucks (custom by_status index): ['HT-215', 'HT-216']
get('HT-214').model (inherited): CAT 793
'HT-999' in repo (inherited __contains__): False
duplicate rejected (contract preserved): Truck 'HT-214' already exists.
after remove('HT-215'), down trucks: ['HT-216']
The same technique implements the other three contracts: a BaseSerializer
for a non-dataclass wire format, a BaseValidator for a contextual rule set,
or a BaseSpecification for a reusable business rule. Each is "subclass, fill
the abstract method, and everything above composes with it unchanged."
Core is not interface-only
Unlike analytics or decision (which ship zero implementations of their
key contract), core ships a working InMemoryRepository. So extending
core is "provide a production-grade implementation of a contract that
already has a reference one", not "the platform refuses to choose for you".
The mechanism - subclass an abstract base - is identical; the intent differs.
14. Exercises¶
- Break identity on purpose. Define a
BaseEntitysubclass withouteq=False. Construct two instances with the same fields but different ids and compare them. Explain the result, then fix it. - A typed id. Define
TruckId(BaseIdentifier[str])andShiftId(BaseIdentifier[str]). Write a function annotated to take aTruckId; try passing aShiftId. What doesmypysay, and why is that the whole point? - Specification algebra. Add an
IsDownand anIsHeavyHaulerspecification over theHaulTruckabove (a heavy hauler being, say,model == "CAT 793"), build "down and a heavy hauler" and "not down" with the operators, and filter aFleetRepositorywith each. - Railway pipeline. Write
parse_tonnage(raw: str) -> Result[float]that fails on non-numeric or negative input, then chain.map/.and_thento convert tonnes to a truck-count estimate - with notryin the caller. - Implement a second repository. Write an
AuditingRepository[E, str]that wraps the contract and records everyadd/removeto an in-memory log, exposing ahistory()method. Which methods must you implement, and which can you leave to the pattern?
15. Reference solutions¶
Solution 1 - Break identity on purpose
@dataclass(frozen=True, slots=True) # note: eq=False MISSING
class Bad(BaseEntity[str]):
name: str
Bad(id="A", name="x") == Bad(id="B", name="x") # True (!) - field equality
__eq__ on the subclass, shadowing
BaseEntity's identity one. Add eq=False to the decorator and the same
comparison becomes False, because equality is now (type, id).
Solution 2 - A typed id
@dataclass(frozen=True, slots=True)
class TruckId(BaseIdentifier[str]): ...
@dataclass(frozen=True, slots=True)
class ShiftId(BaseIdentifier[str]): ...
def dispatch(truck: TruckId) -> None: ...
dispatch(ShiftId("A-2026-06-28")) # mypy: Argument 1 has incompatible type "ShiftId"
str, but they are distinct types, so the checker rejects the
swap that a bare str would have allowed silently.
Solution 3 - Specification algebra
class IsDown(BaseSpecification[HaulTruck]):
def is_satisfied_by(self, c: HaulTruck) -> bool:
return c.status == "down"
class IsHeavyHauler(BaseSpecification[HaulTruck]):
def is_satisfied_by(self, c: HaulTruck) -> bool:
return c.model == "CAT 793"
repo.list(IsDown() & IsHeavyHauler())
repo.list(~IsDown())
Solution 4 - Railway pipeline
Solution 5 - AuditingRepository
You must implement all five abstract methods (add, get, find, remove,
list) - __contains__ comes free from the base. Delegate storage to a
private InMemoryRepository, and append to a log inside add/remove:
class AuditingRepository(BaseRepository[HaulTruck, str]):
def __init__(self) -> None:
self._inner: InMemoryRepository[HaulTruck, str] = InMemoryRepository()
self._log: list[str] = []
def add(self, entity: HaulTruck) -> None:
self._inner.add(entity)
self._log.append(f"add {entity.id}")
def get(self, entity_id: str) -> HaulTruck:
return self._inner.get(entity_id)
def find(self, entity_id: str) -> Maybe[HaulTruck]:
return self._inner.find(entity_id)
def remove(self, entity_id: str) -> None:
self._inner.remove(entity_id)
self._log.append(f"remove {entity_id}")
def list(self, specification: BaseSpecification[HaulTruck] | None = None) -> Sequence[HaulTruck]:
return self._inner.list(specification)
def history(self) -> Sequence[str]:
return tuple(self._log)
16. Further reading¶
corepackage guide - the capability-tour view.coreAPI reference - every symbol, generated from source.- Architecture Handbook - the locked design specifications and the ADRs behind them.
- Fundamentals L02 - Entities · L03 - Value objects - the first contact this tutorial deepens.
Next package tutorial: Ontology (deep) - the mining vocabulary that turns
core's domain-free primitives into equipment, material, locations, and shifts.
(Not yet written - Tutorial 2 of 13.)