Skip to content

Domain Model: Item Catalog

Module: item_catalog Last Updated: 2026-05-10 Generated From: analysis.md


1. Aggregates

Item (Aggregate Root)

Identity: id: UUID

Entities within: none — Item is a flat aggregate; group membership is a plain FK reference to ItemGroup, not a nested entity.

Invariants: - code is immutable after creation and reserved within the tenant namespace even after deactivation - item_type is immutable after creation - storage_unit is immutable after creation - ean is unique within the tenant (enforced at repo level) - shelf_life_days may only be set on item_type ∈ {PRODUCT, INTERMEDIATE} - tags are deduplicated case-insensitively; maximum 20 tags, each max 50 chars - System items (is_system = True) are read-only for tenant actors

Fields

Field Type Nullable Mutable Description
id UUID No No Surrogate primary key
tenant_id UUID No No Owning tenant
code str No No Unique item code within tenant namespace (max 50 chars)
name str No Yes Display name (max 200 chars)
item_type ItemType No No Classification; controls which modules reference the item
storage_unit StorageUnit No No Base unit of measure for all quantities
status ItemStatus No Yes ACTIVE or INACTIVE
is_system bool No No True for GrinSystem-provided items
description str Yes Yes Free-text description (max 500 chars)
tags list[str] No Yes Search/filter labels; empty list if none
group_id UUID Yes Yes FK to ItemGroup; an item belongs to 0 or 1 group
shelf_life_days int Yes Yes Days until LOT expiry; PRODUCT and INTERMEDIATE only
ean str Yes Yes GS1 barcode, unique per tenant (max 20 chars)
created_by UUID No No User who created the item
created_at datetime No No Creation timestamp
updated_by UUID Yes Yes Last user to edit
updated_at datetime Yes Yes Last edit timestamp

Methods

Method Parameters Returns Events Emitted Description
create() code, name, item_type, storage_unit, tenant_id, created_by, description, tags, group_id, shelf_life_days, ean Item ItemCreated Factory — builds a new Active item and emits event
edit() name, description, tags, shelf_life_days, ean, updated_by list[DomainEvent] ItemUpdated (only if any field changed) Updates editable fields; returns empty list on no-op
deactivate() deactivated_by: UUID list[DomainEvent] ItemDeactivated Transitions ACTIVE → INACTIVE
reactivate() reactivated_by: UUID list[DomainEvent] ItemReactivated Transitions INACTIVE → ACTIVE
assign_to_group() group_id: UUID None Replaces any existing group assignment
remove_from_group() None Clears group_id to None

ItemGroup (Aggregate Root)

Identity: id: UUID

Entities within: none — items list their group_id on the Item aggregate; ItemGroup has no child collection.

Invariants: - name is unique within (tenant_id, item_type) case-insensitively - item_type is immutable after creation - Deletion requires zero member items (enforced by handler querying ItemRepository)

Fields

Field Type Nullable Mutable Description
id UUID No No Surrogate primary key
tenant_id UUID No No Owning tenant
name str No Yes Group display name (max 100 chars)
item_type ItemType No No Only items of this type may join the group
created_by UUID No No User who created the group
created_at datetime No No Creation timestamp
updated_by UUID Yes Yes Last user to edit
updated_at datetime Yes Yes Last edit timestamp

Methods

Method Parameters Returns Events Emitted Description
create() name, item_type, tenant_id, created_by ItemGroup Factory
edit() name: str, updated_by: UUID None Updates group name

2. Enumerations

ItemType

class ItemType(str, Enum):
    INGREDIENT = "ingredient"
    PRODUCT = "product"
    INTERMEDIATE = "intermediate"
    PACKAGING = "packaging"
    CONSUMABLE = "consumable"
    SERVICE = "service"
    RESALE = "resale"

shelf_life_days applies to: PRODUCT, INTERMEDIATE only. Warehouse extension skipped for: SERVICE.

StorageUnit

class StorageUnit(str, Enum):
    KG = "kg"
    G = "g"
    L = "l"
    ML = "ml"
    PIECE = "piece"

ItemStatus

class ItemStatus(str, Enum):
    ACTIVE = "active"
    INACTIVE = "inactive"

3. Value Objects

Tags (frozen dataclass)

@dataclass(frozen=True)
class Tags:
    values: tuple[str, ...]  # deduplicated, case-insensitive, max 20 items
Constraint Rule
Max items 20
Max length per tag 50 chars
Deduplication Case-insensitive; duplicate-normalised values are dropped silently

Factory: Tags.from_list(raw: list[str]) -> Tags — normalises and deduplicates input.

EAN (frozen dataclass)

@dataclass(frozen=True)
class EAN:
    value: str  # max 20 chars, non-empty

Validation: non-empty, max 20 chars.


4. State Machine

Item Status Machine

stateDiagram-v2
    [*] --> ACTIVE : Item.create()
    ACTIVE --> INACTIVE : deactivate() [all preconditions met]
    INACTIVE --> ACTIVE : reactivate()
Current State Trigger Method Next State Events Emitted Terminal?
CreateItem command create() ACTIVE ItemCreated No
ACTIVE DeactivateItem command deactivate() INACTIVE ItemDeactivated No
INACTIVE ReactivateItem command reactivate() ACTIVE ItemReactivated No

Invalid Transitions:

From Attempted Trigger Exception Raised
INACTIVE deactivate() InvalidItemStateTransition("Item is already inactive")
ACTIVE reactivate() InvalidItemStateTransition("Item is already active")

ItemGroup Lifecycle

No state machine. An ItemGroup exists or is deleted. Deletion is guarded by a handler-level precondition (member_count == 0), not a domain state.


5. Domain Events

All domain events are frozen dataclasses. They are returned by aggregate methods and written to the transactional outbox by the event publisher, then dispatched in-process to @on_event handlers by the OutboxPoller.

ItemCreated

@dataclass(frozen=True)
class ItemCreated:
    event_id: UUID
    event_type: str = "ItemCreated"
    aggregate_id: UUID          # item_id
    aggregate_type: str = "Item"
    tenant_id: UUID
    timestamp: datetime
    version: int = 1
    # payload
    code: str
    name: str
    item_type: ItemType
    storage_unit: StorageUnit
    shelf_life_days: int | None
    ean: str | None
    is_system: bool
    tags: tuple[str, ...]
    group_id: UUID | None
    created_by: UUID

Emitted by: Item.create() Consumed by: Manufacturing, Warehouse, CRM

ItemUpdated

@dataclass(frozen=True)
class ItemUpdated:
    event_id: UUID
    event_type: str = "ItemUpdated"
    aggregate_id: UUID          # item_id
    aggregate_type: str = "Item"
    tenant_id: UUID
    timestamp: datetime
    version: int = 1
    # payload — always full current values of all editable fields
    name: str
    description: str | None
    tags: tuple[str, ...]
    shelf_life_days: int | None
    old_ean: str | None
    new_ean: str | None
    updated_by: UUID

Emitted by: Item.edit() — only when at least one field value changed. Consumed by: Warehouse, CRM

ItemDeactivated

@dataclass(frozen=True)
class ItemDeactivated:
    event_id: UUID
    event_type: str = "ItemDeactivated"
    aggregate_id: UUID          # item_id
    aggregate_type: str = "Item"
    tenant_id: UUID
    timestamp: datetime
    version: int = 1
    # payload
    code: str
    item_type: ItemType
    deactivated_by: UUID

Emitted by: Item.deactivate() Consumed by: Manufacturing, Warehouse, CRM

ItemReactivated

@dataclass(frozen=True)
class ItemReactivated:
    event_id: UUID
    event_type: str = "ItemReactivated"
    aggregate_id: UUID          # item_id
    aggregate_type: str = "Item"
    tenant_id: UUID
    timestamp: datetime
    version: int = 1
    # payload
    code: str
    item_type: ItemType
    reactivated_by: UUID

Emitted by: Item.reactivate() Consumed by: CRM


6. Repository Interfaces

ItemRepository (ABC)

class ItemRepository(ABC):

    @abstractmethod
    async def get_by_id(self, item_id: UUID, tenant_id: UUID) -> Item:
        """Raises: ItemNotFound"""

    @abstractmethod
    async def get_by_code(self, code: str, tenant_id: UUID) -> Item | None:
        """Case-insensitive lookup. Returns None if not found."""

    @abstractmethod
    async def get_by_ean(self, ean: str, tenant_id: UUID) -> Item | None:
        """Returns None if not found."""

    @abstractmethod
    async def save(self, item: Item) -> None:
        """Insert or update."""

    @abstractmethod
    async def list_items(
        self,
        tenant_id: UUID,
        q: str | None,
        item_types: list[ItemType] | None,
        active: bool | None,
        group_id: UUID | None,
        page: int,
        page_size: int,
    ) -> tuple[list[Item], int]:
        """Full-text, diacritic-insensitive search across name/code/description/group_name/tags.
        Returns (items_on_page, total_count)."""

    @abstractmethod
    async def list_by_group(
        self,
        tenant_id: UUID,
        group_id: UUID,
        page: int,
        page_size: int,
    ) -> tuple[list[Item], int]:
        """Returns (items_on_page, total_count)."""

    @abstractmethod
    async def count_by_group(self, tenant_id: UUID, group_id: UUID) -> int:
        """Used by DeleteItemGroup handler to verify zero members."""

ItemGroupRepository (ABC)

class ItemGroupRepository(ABC):

    @abstractmethod
    async def get_by_id(self, group_id: UUID, tenant_id: UUID) -> ItemGroup:
        """Raises: ItemGroupNotFound"""

    @abstractmethod
    async def get_by_name_and_type(
        self,
        name: str,
        item_type: ItemType,
        tenant_id: UUID,
    ) -> ItemGroup | None:
        """Case-insensitive name lookup within (tenant, item_type). Returns None if not found."""

    @abstractmethod
    async def save(self, group: ItemGroup) -> None:
        """Insert or update."""

    @abstractmethod
    async def delete(self, group_id: UUID, tenant_id: UUID) -> None:
        """Hard delete. Handler must confirm zero members before calling."""

    @abstractmethod
    async def list_groups(
        self,
        tenant_id: UUID,
        item_type: ItemType | None,
        page: int,
        page_size: int,
    ) -> tuple[list[ItemGroup], int]:
        """Returns (groups_on_page, total_count). Each result includes member count from repo query."""

7. Cross-Module Query Ports

These ABCs live in the domain layer and enable in-process deactivation checks. This is a documented exception to the events-only inter-module rule — read-only, synchronous, no state mutation.

class WarehouseItemQueryPort(ABC):
    @abstractmethod
    async def get_stock_quantity(self, item_id: UUID, tenant_id: UUID) -> Decimal:
        """Returns current total stock. Zero means safe to deactivate."""


class ManufacturingItemQueryPort(ABC):
    @abstractmethod
    async def count_open_production_orders(self, item_id: UUID, tenant_id: UUID) -> int:
        """Returns number of open ZPs referencing this item."""

    @abstractmethod
    async def count_published_bom_appearances(self, item_id: UUID, tenant_id: UUID) -> int:
        """Returns number of published recipe BOMs containing this item."""


class CRMItemQueryPort(ABC):
    @abstractmethod
    async def count_open_sales_orders(self, item_id: UUID, tenant_id: UUID) -> int:
        """Returns number of open ZSs referencing this item."""

8. Domain Exceptions

Exception Raised When HTTP Status
ItemNotFound item_id or code does not exist for tenant 404
ItemGroupNotFound group_id does not exist for tenant 404
ItemCodeAlreadyInUse code already taken within tenant namespace 409
EANAlreadyInUse ean already assigned to another item in the tenant 409
SystemItemIsReadOnly tenant actor tries to edit/deactivate a system item 403
SystemGroupIsReadOnly tenant actor tries to edit/delete a system group 403
ImmutableFieldCannotBeChanged attempt to change code, item_type, or storage_unit on edit 400
ShelfLifeDaysNotApplicable shelf_life_days set on non-PRODUCT/INTERMEDIATE item type 400
GroupItemTypeMismatch group.item_type ≠ item.item_type on assign 409
ItemHasStockRemaining stock > 0 during DeactivateItem 409
ItemHasOpenProductionOrders open ZPs exist during DeactivateItem 409
ItemIsInPublishedBOM item appears in published recipe during DeactivateItem 409
ItemHasOpenSalesOrders open ZSs exist during DeactivateItem 409
InvalidItemStateTransition deactivate on inactive item or reactivate on active item 400
ItemGroupNameAlreadyInUse group name taken within (tenant, item_type) 409
ItemGroupHasMembers delete attempted with member_count > 0 409
ImportValidationFailed one or more rows fail validation during ImportItems 400
ImportFileTooLarge file exceeds 5 MB 400
ImportTooManyRows file has more than 500 data rows 400
ItemGroupNotAssigned RemoveItemFromGroup called when item has no group 400