Product — Catalog item you can order (SKU, price, description)
ProductInstance — Physical/serialized item in inventory
Think of it as:
ProductType = "Laptop" (defines what attributes exist)
Product = "ThinkPad X1 Carbon Gen 11" (what you order)
ProductInstance = "Serial #ABC123" (what you receive)
Structure
Attributes
ProductType
Attribute
Type
Required
Description
id
UUID
Yes
Unique identifier
code
String
Yes
Unique code (LAPTOP, TSHIRT)
name
String
Yes
Display name
description
String
No
Category description
parent
ProductType
No
Parent category (null = root)
attributeDefinitions
AttributeDef[]
No
Attributes for this type
active
Boolean
Yes
Is category active?
Product
Attribute
Type
Required
Description
id
UUID
Yes
Unique identifier
sku
String
Yes
Stock Keeping Unit (unique)
name
String
Yes
Product name
description
String
No
Detailed description
productType
ProductType
Yes
Category this belongs to
basePrice
Money
Yes
Base selling price
costPrice
Money
No
Cost/purchase price
attributes
Map
No
Type-specific attributes
status
ProductStatus
Yes
Draft, Active, Discontinued
weight
Decimal
No
Weight in kg
dimensions
Dimensions
No
L × W × H in cm
ProductInstance
Attribute
Type
Required
Description
id
UUID
Yes
Unique identifier
product
Product
Yes
Which product this is
serialNumber
String
No
Unique serial (for serialized)
batchNumber
String
No
Batch/lot number
manufacturedDate
Date
No
When manufactured
expiryDate
Date
No
Expiration date (perishables)
status
InstanceStatus
Yes
Available, Sold, Damaged, etc.
AttributeDefinition
Attribute
Type
Required
Description
code
String
Yes
Attribute code (RAM, SIZE)
name
String
Yes
Display name
dataType
Enum
Yes
String, Integer, Decimal, Boolean, Date, Enum
required
Boolean
Yes
Must be set on Product?
options
String[]
No
Valid values (for Enum type)
unit
String
No
Unit of measure (GB, cm)
minValue
Decimal
No
Minimum allowed value
maxValue
Decimal
No
Maximum allowed value
Behaviors
Entity ProductType:
function isLeaf(): Boolean
return children is empty
function isRoot(): Boolean
return parent is None
function getPath(): String[]
// Returns ["Electronics", "Computers", "Laptops"]
if parent is None:
return [name]
return parent.getPath() + [name]
function getAllAttributeDefinitions(): AttributeDefinition[]
// Inherit from ancestors + own
inherited = parent?.getAllAttributeDefinitions() or []
return inherited + attributeDefinitions
function canHaveProducts(): Boolean
// Only leaf types can have products directly
return isLeaf()
function getDescendants(): ProductType[]
result = children.copy()
for child in children:
result += child.getDescendants()
return result
Entity Product:
function isAvailable(): Boolean
return status == ProductStatus.Active
function getAttribute(code: String): Any
return attributes.get(code)
function setAttribute(code: String, value: Any):
definition = productType.getAllAttributeDefinitions()
.find(d => d.code == code)
require definition is not None // Attribute must be defined
require definition.validate(value) // Value must be valid
attributes[code] = value
function validate(): ValidationResult
errors = []
for definition in productType.getAllAttributeDefinitions():
value = attributes.get(definition.code)
if definition.required and value is None:
errors.add("Missing required attribute: " + definition.code)
if value is not None and not definition.validate(value):
errors.add("Invalid value for " + definition.code)
return ValidationResult(isValid: errors.isEmpty(), errors: errors)
function calculatePrice(customer: Customer): Money
price = basePrice
// Apply customer-specific pricing
if customer.loyaltyTier == "Gold":
price = price * 0.95 // 5% discount
// Apply quantity discounts, promotions, etc.
return price
function discontinue():
require status == ProductStatus.Active
status = ProductStatus.Discontinued
emit ProductDiscontinued(this)
Entity ProductInstance:
function isAvailable(): Boolean
return status == InstanceStatus.Available
function isExpired(): Boolean
return expiryDate is not None and expiryDate < today()
function sell(order: Order):
require isAvailable()
require not isExpired()
status = InstanceStatus.Sold
soldTo = order
soldAt = now()
emit InstanceSold(this, order)
function markDamaged(reason: String):
status = InstanceStatus.Damaged
damageReason = reason
damagedAt = now()
Entity AttributeDefinition:
function validate(value: Any): Boolean
if value is None:
return not required
// Type check
match dataType:
case DataType.String:
if not (value is String): return false
case DataType.Integer:
if not (value is Integer): return false
case DataType.Decimal:
if not (value is Number): return false
case DataType.Boolean:
if not (value is Boolean): return false
case DataType.Enum:
if value not in options: return false
// Range check
if minValue is not None and value < minValue:
return false
if maxValue is not None and value > maxValue:
return false
return true
// Product (catalog item)
thinkpad = new Product(
sku: "LEN-X1C-G11-16-512",
name: "ThinkPad X1 Carbon Gen 11 (16GB/512GB)",
productType: laptops,
basePrice: Money(649900, "PLN"), // 6499.00 PLN
status: ProductStatus.Draft
)
thinkpad.setAttribute("RAM_GB", 16)
thinkpad.setAttribute("STORAGE_GB", 512)
thinkpad.setAttribute("SCREEN_SIZE", 14.0)
thinkpad.validate() // isValid: true
thinkpad.publish() // status = Active
// ProductInstance (specific unit in warehouse)
unit1 = new ProductInstance(
product: thinkpad,
serialNumber: "PF4ABC123",
manufacturedDate: 2025-01-15,
status: InstanceStatus.Available
)
State Machines
Product Status
Instance Status
Invariants
SKU unique — No two products share the same SKU
Serial unique — No two instances share the same serial number
Type hierarchy acyclic — ProductType cannot be its own ancestor
Products on leaf types — Products can only belong to leaf ProductTypes
Required attributes set — Active products must have all required attributes
Valid attribute values — Attribute values must match their definitions
Error Handling
Operation
Precondition Violated
Error
setAttribute(code, value)
Attribute not defined on type
PreconditionError: unknown attribute 'FOO' for type Laptop
setAttribute(code, value)
Value fails validation
PreconditionError: RAM_GB must be between 4 and 128
discontinue()
Status is not Active
PreconditionError: can only discontinue active products
ProductInstance.sell(order)
Not available
PreconditionError: instance is not available (status: Sold)
ProductInstance.sell(order)
Expired
PreconditionError: instance has expired
validate()
Missing required attributes
Returns ValidationResult(isValid: false, errors: [...]) (no exception)
SQL Schema
sql-- Product type hierarchyCREATE TABLE product_type (
id UUID PRIMARY KEY,
code VARCHAR(50) NOT NULLUNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
parent_id UUID REFERENCES product_type(id),
active BOOLEANNOT NULLDEFAULTTRUE,
created_at TIMESTAMPNOT NULLDEFAULTCURRENT_TIMESTAMP
);
-- Attribute definitions for product typesCREATE TABLE attribute_definition (
id UUID PRIMARY KEY,
product_type_id UUID NOT NULLREFERENCES product_type(id) ONDELETE CASCADE,
code VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
data_type VARCHAR(20) NOT NULL, -- 'String', 'Integer', 'Decimal', 'Boolean', 'Enum'
required BOOLEANNOT NULLDEFAULTFALSE,
options TEXT, -- JSON array for Enum type: '["S", "M", "L", "XL"]'
unit VARCHAR(20),
min_value DECIMAL,
max_value DECIMAL,
sort_order INTEGERNOT NULLDEFAULT0,
UNIQUE(product_type_id, code)
);
-- Products (catalog items)CREATE TABLE product (
id UUID PRIMARY KEY,
sku VARCHAR(50) NOT NULLUNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT,
product_type_id UUID NOT NULLREFERENCES product_type(id),
base_price_cents INTEGERNOT NULL,
base_price_currency CHAR(3) NOT NULLDEFAULT'PLN',
cost_price_cents INTEGER,
cost_price_currency CHAR(3),
status VARCHAR(20) NOT NULLDEFAULT'Draft',
weight_kg DECIMAL(10,3),
length_cm DECIMAL(10,2),
width_cm DECIMAL(10,2),
height_cm DECIMAL(10,2),
created_at TIMESTAMPNOT NULLDEFAULTCURRENT_TIMESTAMP,
updated_at TIMESTAMPNOT NULLDEFAULTCURRENT_TIMESTAMP,
CONSTRAINT chk_product_status CHECK (status IN ('Draft', 'Active', 'Discontinued', 'Deleted'))
);
-- Product attribute values (EAV pattern)CREATE TABLE product_attribute (
product_id UUID NOT NULLREFERENCES product(id) ONDELETE CASCADE,
attribute_code VARCHAR(50) NOT NULL,
value_string VARCHAR(255),
value_integer INTEGER,
value_decimal DECIMAL(15,4),
value_boolean BOOLEAN,
value_date DATE,
PRIMARY KEY (product_id, attribute_code)
);
-- Product instances (serialized items)CREATE TABLE product_instance (
id UUID PRIMARY KEY,
product_id UUID NOT NULLREFERENCES product(id),
serial_number VARCHAR(100) UNIQUE,
batch_number VARCHAR(50),
manufactured_date DATE,
expiry_date DATE,
status VARCHAR(20) NOT NULLDEFAULT'Available',
created_at TIMESTAMPNOT NULLDEFAULTCURRENT_TIMESTAMP,
CONSTRAINT chk_instance_status CHECK (
status IN ('Available', 'Reserved', 'Sold', 'Damaged', 'Expired', 'Returned')
)
);
-- IndexesCREATE INDEX idx_product_type ON product(product_type_id);
CREATE INDEX idx_product_status ON product(status);
CREATE INDEX idx_product_type_parent ON product_type(parent_id);
CREATE INDEX idx_instance_product ON product_instance(product_id);
CREATE INDEX idx_instance_status ON product_instance(status);
CREATE INDEX idx_instance_expiry ON product_instance(expiry_date) WHERE expiry_date ISNOT NULL;
Common Queries
sql-- Get product with all attributesSELECT
p.*,
json_object_agg(pa.attribute_code,
COALESCE(pa.value_string, pa.value_integer::text,
pa.value_decimal::text, pa.value_boolean::text,
pa.value_date::text)
) as attributes
FROM product p
LEFTJOIN product_attribute pa ON pa.product_id = p.id
WHERE p.id = :productId
GROUPBY p.id;
-- Get products by type (including subtypes)WITHRECURSIVE type_tree AS (
SELECT id FROM product_type WHERE id = :typeId
UNIONALLSELECT pt.id FROM product_type pt
JOIN type_tree tt ON pt.parent_id = tt.id
)
SELECT p.*FROM product p
WHERE p.product_type_id IN (SELECT id FROM type_tree)
AND p.status ='Active';
-- Get type hierarchy pathWITHRECURSIVE type_path AS (
SELECT id, name, parent_id, 1as level
FROM product_type WHERE id = :typeId
UNIONALLSELECT pt.id, pt.name, pt.parent_id, tp.level +1FROM product_type pt
JOIN type_path tp ON pt.id = tp.parent_id
)
SELECT name FROM type_path ORDERBY level DESC;
-- Find available instances with earliest expiry (FEFO)SELECT pi.*FROM product_instance pi
WHERE pi.product_id = :productId
AND pi.status ='Available'AND (pi.expiry_date ISNULLOR pi.expiry_date >CURRENT_DATE)
ORDERBY pi.expiry_date NULLS LAST
LIMIT :quantity;
Design Considerations
EAV vs JSON vs Wide Table
Approach
Pros
Cons
EAV
Flexible, sparse attributes
Complex queries, no type safety
JSON column
Flexible, single column
Limited indexing, validation
Wide table
Fast, typed, indexable
Schema changes, many NULLs
Recommendation:
EAV for truly dynamic attributes
JSON for semi-structured data
Dedicated columns for frequently queried/filtered attributes
When to Use ProductInstance
Scenario
Use Instance?
Why
Serialized electronics
Yes
Track individual units
Perishable goods
Yes
Track expiry dates
Generic commodities (screws)
No
Just track quantity
Digital products
No
No physical instance
Made-to-order items
Yes
Track individual pieces
Pricing Strategies
Product archetype supports various pricing:
Base price — On Product entity
Customer tier pricing — Calculated at runtime
Quantity breaks — Separate PriceBreak entity
Time-limited promotions — Promotion entity with dates