01. Domain Modeling

Overview

Context diagram for the allocation service

MADE.com is a successful furniture retailer. We source our furniture from manufacturers all over the world and sell it across Europe.

We have separate systems that are responsible for

  • buying stock,

  • selling stock to customers,

  • and shipping goods to customers.

A system in the middle needs to coordinate the process by allocating stock to a customer’s orders;

[5]:
%%plantuml

@startuml allocation
!include images/C4_Context.puml
scale 0.7

System(systema, "Allocation", "Allocates stock to customer orders")

Person(customer, "Customer", "Wants to buy furniture")
Person(buyer, "Buying Team", "Needs to purchase furniture from suppliers")

System(procurement, "Purchasing", "Manages workflow for buying stock from suppliers")
System(ecom, "Ecommerce", "Sells goods online")
System(warehouse, "Warehouse", "Manages workflow for shipping goods to customers")

Rel(buyer, procurement, "Uses")
Rel(procurement, systema, "Notifies about shipments")
Rel(customer, ecom, "Buys from")
Rel(ecom, systema, "Asks for stock levels")
Rel(ecom, systema, "Notifies about orders")
Rel_R(systema, warehouse, "Sends instructions to")
Rel_U(warehouse, customer, "Dispatches goods to")

@enduml
[5]:
../_images/guide_01_2_0.svg

Exploring the Domain Language

  • Product

    • identified by SKU(Stock Keeping Unit)

  • Order

    • identified by an order reference

    • comprises mutliple order lines

  • OrderLine

    • has a SKU and a quantity

  • Batch

    • has a unique ID(reference), a SKU, and a quantity

    • has an ETA if they are currently shipping

      • or they may be in warehouse stock.

  • When we allocate x units of stock to a batch, the available quantity is reduced by x.

[7]:
%%plantuml

@startuml domain-model

left to right direction

class Product {
    sku: SKU
}

class Order {
    reference: OrderReference
    _order_lines: [OrderLine]
}

class OrderLine {
    orderid: OrderReference
    sku: SKU
    qty: int
}

class Batch {
    reference: BatchReference
    sku: SKU
    quantity: int
    _allocations: [OrderLine]
}

Order::_order_lines o-- OrderLine
Batch::_allocations o-- OrderLine
OrderLine::sku -- Product
Batch::sku -- Product

@enduml
[7]:
../_images/guide_01_5_0.svg

Unit Testing Domain Models

[3]:
# 단위 테스트를 위한 헬퍼 데코레이터

import sys
import traceback

import pytest

VIOLET = '\033[95m'
ENDC = '\033[0m'
BOLD = '\033[1m'

def test(func):
    try:
        func() # 함수를 정의할때 바로 실행되도록
        print(f'✅ {VIOLET}{func.__name__}{ENDC}')
    except:
        exc_args = sys.exc_info()
        traceback.print_exception(*exc_args, limit=-1)
    finally:
        # 함수 실행이 실패해도 함수 정의는 그대로 리턴하도록
        return func

Batch 도메인이 정의되지 않아서 테스트가 실패합니다.

[4]:
@test
def test_allocating_to_a_batch_reduces_the_available_quantity():
    batch = Batch("batch-001", "SMALL-TABLE", qty=20, eta=date.today())
    line = OrderLine('order-ref', "SMALL-TABLE", 2)

    batch.allocate(line)

    assert batch.available_quantity == 18
Traceback (most recent call last):
  File "<ipython-input-4-ec9a4771936d>", line 3, in test_allocating_to_a_batch_reduces_the_available_quantity
    batch = Batch("batch-001", "SMALL-TABLE", qty=20, eta=date.today())
NameError: name 'Batch' is not defined
[5]:
%%typecheck first_cut

from dataclasses import dataclass
from typing import Optional
from datetime import date

@dataclass(frozen=True)
class OrderLine:
    orderid: str
    sku: str
    qty: int


class Batch:
    def __init__(
        self, ref: str, sku: str, qty: int, eta: Optional[date]
    ):
        self.reference = ref
        self.sku = sku
        self.eta = eta
        self.available_quantity = qty

    def allocate(self, line: OrderLine) -> None:
        self.available_quantity -= line.qty
Typecheck: success

도메인 객체 정의 후 테스트 성공합니다.

[6]:
@test
def test_allocating_to_a_batch_reduces_the_available_quantity():
    batch = Batch("batch-001", "SMALL-TABLE", qty=20, eta=date.today())
    line = OrderLine('order-ref', "SMALL-TABLE", 2)

    batch.allocate(line)

    assert batch.available_quantity == 18
test_allocating_to_a_batch_reduces_the_available_quantity

can_allocate 가 정의되지 않아 실패하는 테스트 코드를 먼저 작성합니다.

[7]:
def make_batch_and_line(sku, batch_qty, line_qty):
    return (
        Batch("batch-001", sku, batch_qty, eta=date.today()),
        OrderLine("order-123", sku, line_qty)
    )

@test
def test_can_allocate_if_available_greater_than_required():
    large_batch, small_line = make_batch_and_line("ELEGANT-LAMP", 20, 2)
    assert large_batch.can_allocate(small_line)
Traceback (most recent call last):
  File "<ipython-input-7-9ae6c0e6fdad>", line 10, in test_can_allocate_if_available_greater_than_required
    assert large_batch.can_allocate(small_line)
AttributeError: 'Batch' object has no attribute 'can_allocate'

테스트가 성공하도록 Batch 객체에 can_allocate를 정의합니다.

[8]:
%%typecheck batch_alloc from first_cut[OrderLine]
from datetime import date

class Batch:
    def __init__(
        self, ref: str, sku: str, qty: int, eta: Optional[date]
    ):
        self.reference = ref
        self.sku = sku
        self.eta = eta
        self._purchased_quantity = qty
        self._allocations = set[OrderLine]()

    def allocate(self, line: OrderLine) -> None:
        if self.can_allocate(line):
            self._allocations.add(line)

    def deallocate(self, line: OrderLine) -> None:
        if line in self._allocations:
            self._allocations.remove(line)

    @property
    def allocated_quantity(self) -> int:
        return sum(line.qty for line in self._allocations)

    @property
    def available_quantity(self) -> int:
        return self._purchased_quantity - self.allocated_quantity

    def can_allocate(self, line: OrderLine) -> bool:
        return self.sku == line.sku and self.available_quantity >= line.qty
Typecheck: success

테스트 성공

[9]:
@test
def test_can_allocate_if_available_greater_than_required():
    large_batch, small_line = make_batch_and_line("ELEGANT-LAMP", 20, 2)
    assert large_batch.can_allocate(small_line)

@test
def test_cannot_allocate_if_available_smaller_than_required():
    small_batch, large_line = make_batch_and_line("ELEGANT-LAMP", 2, 20)
    assert small_batch.can_allocate(large_line) is False

@test
def test_can_allocate_if_available_equal_to_required():
    batch, line = make_batch_and_line("ELEGANT-LAMP", 2, 2)
    assert batch.can_allocate(line)

@test
def test_cannot_allocate_if_skus_do_not_match():
    batch = Batch("batch-001", "UNCOMFORTABLE-CHAIR", 100, eta=None)
    different_sku_line = OrderLine("order-123", "EXPENSIVE-TOASTER", 10)
    assert batch.can_allocate(different_sku_line) is False
test_can_allocate_if_available_greater_than_requiredtest_cannot_allocate_if_available_smaller_than_requiredtest_can_allocate_if_available_equal_to_requiredtest_cannot_allocate_if_skus_do_not_match
[10]:
@test
def test_can_only_deallocate_allocated_lines():
    batch, unallocated_line = make_batch_and_line("DECORATIVE-TRINKET", 20, 2)
    batch.deallocate(unallocated_line)
    assert batch.available_quantity == 20
test_can_only_deallocate_allocated_lines
[11]:
@test
def test_allocation_is_idempotent():
    batch, line = make_batch_and_line("ANGULAR-DESK", 20, 2)
    batch.allocate(line)
    batch.allocate(line)
    assert batch.available_quantity == 18
test_allocation_is_idempotent
[12]:
%%typecheck custom_types from batch_alloc
from typing import NewType

Quantity = NewType("Quantity", int)
Sku = NewType("Sku", str)
Reference = NewType("Reference", str)
OrderReference = NewType("OrderReference", str)
ProductReference = NewType("ProductReference", str)
Typecheck: success
[13]:
%%typecheck orderline from custom_types

@dataclass(frozen=True)
class OrderLine:
    orderid: OrderReference
    sku: Sku
    qty: Quantity
Typecheck: success
[14]:
from dataclasses import dataclass
from typing import NamedTuple
from collections import namedtuple

@dataclass(frozen=True)
class Name:
    first_name: str
    surname: str

class Money(NamedTuple):
    currency: str
    value: int

Line = namedtuple('Line', ['sku', 'qty'])
[15]:
@test
def test_equality():
    assert Money('gbp', 10) == Money('gbp', 10)
    assert Name('Harry', 'Percival') != Name('Bob', 'Gregory')
    assert Line('RED-CHAIR', 5) == Line('RED-CHAIR', 5)
test_equality
[16]:
fiver = Money('gbp', 5)
tenner = Money('gbp', 10)

@test
def can_add_money_values_for_the_same_currency():
    assert fiver + fiver == tenner
Traceback (most recent call last):
  File "<ipython-input-16-3e32c9afbc08>", line 6, in can_add_money_values_for_the_same_currency
    assert fiver + fiver == tenner
AssertionError
[17]:
class Money(NamedTuple):
    currency: str
    value: int

    def __add__(self, rhs: Money):
        if self.currency != rhs.currency: raise ValueError
        return Money(self.currency, self.value + rhs.value)

    def __sub__(self, rhs: Money):
        assert self.currency == rhs.currency
        return Money(self.currency, self.value - rhs.value)

    def __mul__(self, rhs: int):
        if not isinstance(rhs, (int, float)):
            raise TypeError
        return Money(self.currency, self.value * rhs)
[18]:
fiver = Money('gbp', 5)
tenner = Money('gbp', 10)

@test
def can_add_money_values_for_the_same_currency():
    assert fiver + fiver == tenner

@test
def can_subtract_money_values():
    assert tenner - fiver == fiver

@test
def adding_different_currencies_fails():
    with pytest.raises(ValueError):
        Money('usd', 10) + Money('gbp', 10)

@test
def can_multiply_money_by_a_number():
    assert fiver * 5 == Money('gbp', 25)

@test
def multiplying_two_money_values_is_an_error():
    with pytest.raises(TypeError):
        tenner * fiver
can_add_money_values_for_the_same_currencycan_subtract_money_valuesadding_different_currencies_failscan_multiply_money_by_a_numbermultiplying_two_money_values_is_an_error

Value Objects and Entities

A name itself cannot change…

[19]:
@test
def test_name_equality():
    assert Name("Harry", "Percival") != Name("Barry", "Percival")
test_name_equality

But a person can!

엔티티는 값과 달리 특정 id 속성값으로 동등성을 검사합니다. 객체 속성 바뀌어도 id가 같다면 같은 객체입니다.

[20]:
class Person:
    def __init__(self, name: Name):
        self.name = name
[21]:
@test
def test_barry_is_harry():
    harry = Person(Name("Harry", "Percival"))
    barry = harry

    barry.name = Name("Barry", "Percival")

    assert harry is barry and barry is harry
test_barry_is_harry

Implementing equality opeartors

[22]:
@test
def test_batch_equality():
    batch1 = Batch('batch-001', 'SIMPLE-TABLE', 10, eta=date.today())
    batch2 = Batch('batch-001', 'SIMPLE-CHAIR', 5, eta=date.today())

    assert batch1 == batch2
Traceback (most recent call last):
  File "<ipython-input-22-7e6a1737e553>", line 6, in test_batch_equality
    assert batch1 == batch2
AssertionError

Batch 엔티티에도 동일성을 검사할 수 있는 매직 메소드를 추가해봅시다.

[23]:
%%typecheck batch_entity from custom_types, orderline
from datetime import date

class Batch:
    def __init__(
        self, ref: Reference, sku: Sku, qty: int, eta: Optional[date]
    ):
        self.__reference = ref
        self.sku = sku
        self.eta = eta
        self._purchased_quantity = qty
        self._allocations = set[OrderLine]()

    @property
    def reference(self) -> Reference:
        return self.__reference

    def allocate(self, line: OrderLine) -> None:
        if self.can_allocate(line):
            self._allocations.add(line)

    def deallocate(self, line: OrderLine) -> None:
        if line in self._allocations:
            self._allocations.remove(line)

    @property
    def allocated_quantity(self) -> int:
        return sum(line.qty for line in self._allocations)

    @property
    def available_quantity(self) -> int:
        return self._purchased_quantity - self.allocated_quantity

    def can_allocate(self, line: OrderLine) -> bool:
        return self.sku == line.sku and self.available_quantity >= line.qty

    #######################################################################
    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Batch):
            return False
        return other.reference == self.reference

    def __hash__(self) -> int:
        return hash(self.reference)
Typecheck: success
[24]:
@test
def test_batch_equality():
    batch1 = Batch('batch-001', 'SIMPLE-TABLE', 10, eta=date.today())
    batch2 = Batch('batch-001', 'SIMPLE-CHAIR', 5, eta=date.today())

    assert batch1 == batch2
test_batch_equality

Not Everything Has to Be an Object: A Domain Service Function

Testing our domain services

정의되지 않은 allocate 함수(서비스)에 대한 실패하는 테스트를 먼저 작성합니다.

[25]:
from datetime import date, timedelta
tomorrow = date.today() + timedelta(days=1)

@test
def test_prefers_current_stock_batches_to_shipments():
    in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None)
    shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow)
    line = OrderLine("oref", "RETRO-CLOCK", 10)

    allocate(line, [in_stock_batch, shipment_batch])

    assert in_stock_batch.available_quantity == 90
    assert shipment_batch.available_quantity == 100
Traceback (most recent call last):
  File "<ipython-input-25-c27cf706fffa>", line 10, in test_prefers_current_stock_batches_to_shipments
    allocate(line, [in_stock_batch, shipment_batch])
NameError: name 'allocate' is not defined

A standalone function for our domain service

Batch 객체의 순서를 정의할 수 없으므로 sorted 를 적용할 수 없어 에러 발생합니다.

[26]:
%%typecheck allocate from orderline, batch_entity
from typing import List

def allocate(line: OrderLine, batches: List[Batch]) -> str:
    batch = next(
        b for b in sorted(batches) if b.can_allocate(line)
    )
    batch.allocate(line)
    return batch.reference
<string>:6: error: Value of type variable "_SupportsLessThanT" of "sorted" cannot be "Batch"

Python’s Magic Methods Let Us Use Our Models with Idiomatic Python

__lt__ 매직 메소드를 구현하여 Batch 객체들의 정렬이 가능하도록 합시다.

[27]:
%%typecheck batch_ord from custom_types, orderline
from datetime import date

class Batch:
    def __init__(
        self, ref: Reference, sku: Sku, qty: int, eta: Optional[date]
    ):
        self.__reference = ref
        self.sku = sku
        self.eta = eta
        self._purchased_quantity = qty
        self._allocations = set[OrderLine]()

    @property
    def reference(self) -> Reference:
        return self.__reference

    def allocate(self, line: OrderLine) -> None:
        if self.can_allocate(line):
            self._allocations.add(line)

    def deallocate(self, line: OrderLine) -> None:
        if line in self._allocations:
            self._allocations.remove(line)

    @property
    def allocated_quantity(self) -> int:
        return sum(line.qty for line in self._allocations)

    @property
    def available_quantity(self) -> int:
        return self._purchased_quantity - self.allocated_quantity

    def can_allocate(self, line: OrderLine) -> bool:
        return self.sku == line.sku and self.available_quantity >= line.qty

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Batch):
            return False
        return other.reference == self.reference

    def __hash__(self) -> int:
        return hash(self.reference)

    #######################################################################
    def __lt__(self, other: Batch) -> bool:
        if self.eta is None:
            return True
        if other.eta is None:
            return False
        return self.eta < other.eta
Typecheck: success

이제 Batch 객체의 정렬이 가능하여 타입체크에 성공하고 테스트들도 성공적으로 수행됩니다.

[28]:
%%typecheck allocate from batch_ord, orderline
from typing import List

def allocate(line: OrderLine, batches: List[Batch]) -> str:
    batch = next(
        b for b in sorted(batches) if b.can_allocate(line)
    )
    batch.allocate(line)
    return batch.reference
Typecheck: success
[29]:
from datetime import date, timedelta

today, tomorrow = date.today(), date.today() + timedelta(days=1)

@test
def test_prefers_current_stock_batches_to_shipments():
    in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None)
    shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow)
    line = OrderLine("oref", "RETRO-CLOCK", 10)

    allocate(line, [in_stock_batch, shipment_batch])

    assert in_stock_batch.available_quantity == 90
    assert shipment_batch.available_quantity == 100
test_prefers_current_stock_batches_to_shipments
[30]:
from datetime import date, timedelta

today = date.today()
tomorrow, later = today + timedelta(days=1), today + timedelta(days=30)

@test
def test_prefers_earlier_batches():
    earliest = Batch("speedy-batch", "MINIMALIST-SPOON", 100, eta=today)
    medium = Batch("normal-batch", "MINIMALIST-SPOON", 100, eta=tomorrow)
    latest = Batch("slow-batch", "MINIMALIST-SPOON", 100, eta=later)
    line = OrderLine("order1", "MINIMALIST-SPOON", 10)

    allocate(line, [medium, earliest, latest])

    assert earliest.available_quantity == 90
    assert medium.available_quantity == 100
    assert latest.available_quantity == 100

@test
def test_returns_allocated_batch_ref():
    in_stock_batch = Batch("in-stock-batch-ref", "HIGHBROW-POSTER", 100, eta=None)
    shipment_batch = Batch("shipment-batch-ref", "HIGHBROW-POSTER", 100, eta=tomorrow)
    line = OrderLine("oref", "HIGHBROW-POSTER", 10)
    allocation = allocate(line, [in_stock_batch, shipment_batch])
    assert allocation == in_stock_batch.reference
test_prefers_earlier_batchestest_returns_allocated_batch_ref

Exceptions Can Express Domain Concetps Too

pytest 를 이용해 할당할 Stock 이 없을 경우 OutOfStock 예외가 발생하는지 테스트합시다.

[31]:
class OutOfStock(Exception):
    pass

@test
def test_raises_out_of_stock_exception_if_cannot_allocate():
    batch = Batch('batch1', 'SMALL-FORK', 10, eta=today)
    allocate(OrderLine('order1', 'SMALL-FORK', 10), [batch])

    with pytest.raises(OutOfStock, match='SMALL-FORK'):
        allocate(OrderLine('order2', 'SMALL-FORK', 1), [batch])
Traceback (most recent call last):
  File "<ipython-input-28-8c58e5803c91>", line 4, in allocate
    batch = next(
StopIteration

allocate 함수에 예외발생 부분을 추가

[32]:
def allocate(line: OrderLine, batches: List[Batch]) -> str:
    try:
        batch = next(
            b for b in sorted(batches) if b.can_allocate(line)
        )
        batch.allocate(line)
        return batch.reference
    except StopIteration:
        raise OutOfStock(f'Out of stock for sku {line.sku}')

이제 테스트가 성공합니다.

[33]:
@test
def test_raises_out_of_stock_exception_if_cannot_allocate():
    batch = Batch('batch1', 'SMALL-FORK', 10, eta=today)
    allocate(OrderLine('order1', 'SMALL-FORK', 10), [batch])

    with pytest.raises(OutOfStock, match='SMALL-FORK'):
        allocate(OrderLine('order2', 'SMALL-FORK', 1), [batch])
test_raises_out_of_stock_exception_if_cannot_allocate

Domain Modeling Recap

This is the part of your code that is closest to the business, the most likely to change, and the place where you deliver the most value to the business. Make it easy to understand and modify.

A value object is defined by its attributes. It’s usually best implemented as an immutable type. If you change an attribute on a Value Object, it represents a different object. In contrast, an entity has attributes that may vary over time and it will still be the same entity. It’s important to define what does uniquely identify an entity (usually some sort of name or reference field).

Python is a multiparadigm language, so let the “verbs” in your code be functions. For every FooManager, BarBuilder, or BazFactory, there’s often a more expressive and readable manage_foo(), build_bar(), or get_baz() waiting to happen.

Revisit the SOLID principles and all the other good heuristics like “has a versus is-a,” “prefer composition over inheritance,” and so on.