House-move inventory: A core that knows nothing about HTTP - Part 2

House-move inventory: A core that knows nothing about HTTP - Part 2

Building Inventory System

~8 min read

Post 1 described rooms, boxes and items. This post is about where the rules about them live, because that decision has paid for itself more often than any other in the project.

The rule is one sentence in CLAUDE.md: inventory domain logic lives only in inventory.core. The API and the CLIs are thin translation layers - no rules, no validation logic, no aggregation. If a behaviour matters (quantity must be at least 1, a box that isn't going can have a reason, clones copy their source's decision but reset to "packing"), it's in core.py, and there's exactly one place to read it.

Talking to storage through a Protocol

core.py never imports sqlite3. It talks to storage through a Repository protocol in repository.py:

class Repository(Protocol):
    def get_box_by_code(self, code: str) -> Box | None: ...
    def list_boxes(...) -> list[Box]: ...
    def add_item(self, item: Item) -> Item: ...
    ...

SqliteRepository implements it, and it is persistence only - no business rules sneak in there either. factory.py is the one place that wires the two together (open_service(), open_api_service()), so nothing else needs to know which storage is in use.

Why bother with a Protocol for a project with a single database? Three reasons that all turned out to be real:

  • Tests can use anything. Core tests run against a temp-file database through the same wiring as production.
  • It keeps me honest. If I catch myself wanting to put an if in a SQL query, the Protocol is the thing that makes me notice I'm leaking a rule into the wrong layer.
  • It documents the surface. The list of repository methods is a precise inventory of what the domain needs from storage.

Time is a dependency too

The other injectable is the clock. InventoryService takes a clock callable, and the services use self._clock(), never datetime.now(). Timestamps are always timezone-aware UTC, and tests pass a FakeClock they can advance:

now = self._clock()
box = self._repo.add_box(Box(code=code, room=resolved.name, ..., created_at=now))

This looks fussy until you write the box-history feature (post 11), where the tests need to assert that "renamed" happened after "created". With a real clock that's a sleep or a flaky comparison; with a fake one it's an assertion.

Errors are domain errors

errors.py holds a small hierarchy - DuplicateBoxCode, ValidationError, TranslationUnavailable, and so on. The core raises them; it has no idea what an HTTP status is. The API layer owns a single mapping from error class to status code (validation errors become 422, "not found" 404, duplicates 409). That is the entire contract between the layers, and it means a new error type needs one line in one dict.

What "thin" actually means in the API

A route handler in app.py does three things: take validated input, call one service method, shape the output. Here's the core of the all-rooms export from post 12, minus its options:

sheets = svc.all_room_sheets(status=status, decision=decision)

The aggregation - "every room with at least one matching box" - is a method on the service, not a loop in the route. The loop that is in the route (rendering each room's sheet and merging the PDFs) is formatting, which the architecture rules allow: render/ is "pure formatting over domain objects - no storage access".

The test for whether a layer is thin: could I add a CLI that does the same job by calling the same service methods, and get identical behaviour for free? Here the answer is yes, which is exactly what the planned thin inventory CLI relies on.

The cost

It is more files and more indirection than a single app.py with SQL in the handlers, and for the first day it felt like ceremony. The payoff arrived in the first week of using it for real. Changing a default (the currency moved from "whatever you typed" to GBP) meant editing one function, and the failing tests told me exactly which assertions had hard-coded the old value - which is why CLAUDE.md now carries the reminder to grep tests/ for the old value whenever a domain default changes.

By the numbers: when the REST API landed (Phase 2) the whole project had 57 tests. It has nearly 400 backend tests now, and the layering is the reason I trust adding to it.

Next: the money rules, and why the euro figure is deliberately a bit embarrassing.


Previous: ← Why a spreadsheet wasn't enough · Next: Money, currencies and an approximate euro →