콘텐츠로 이동

도메인 모델링

Spakky Domain은 DDD(Domain-Driven Design)의 빌딩 블록을 제공합니다.


Value Object

값으로 동등성을 판단하는 불변 객체입니다. ID가 없고, 모든 속성이 같으면 같은 객체입니다.

from spakky.core.common.mutability import immutable
from spakky.domain.models.value_object import AbstractValueObject

@immutable
class Money(AbstractValueObject):
    amount: float
    currency: str

    def validate(self) -> None:
        if self.amount < 0:
            raise ValueError("금액은 0 이상이어야 합니다")

# 값이 같으면 동일
price_a = Money(amount=1000, currency="KRW")
price_b = Money(amount=1000, currency="KRW")
assert price_a == price_b
assert hash(price_a) == hash(price_b)

# 복제
price_c = price_a.clone()
assert price_a == price_c

Entity

ID로 동등성을 판단하는 가변 객체입니다. 속성이 달라도 ID가 같으면 같은 엔티티입니다.

from typing import Self
from uuid import UUID, uuid4
from spakky.core.common.mutability import mutable
from spakky.domain.models.entity import AbstractEntity

@mutable
class User(AbstractEntity[UUID]):
    name: str
    email: str

    def validate(self) -> None:
        if not self.name:
            raise ValueError("이름은 필수입니다")

    @classmethod
    def next_id(cls) -> UUID:
        return uuid4()

    @classmethod
    def create(cls: type[Self], name: str, email: str) -> Self:
        return cls(uid=cls.next_id(), name=name, email=email)

# ID가 같으면 동일
user_id = UUID("12345678-1234-5678-1234-567812345678")
user_a = User(uid=user_id, name="John", email="john@example.com")
user_b = User(uid=user_id, name="Sarah", email="sarah@example.com")
assert user_a == user_b  # ID 기반 동등성

# 새 ID = 다른 엔티티
user_c = User.create(name="Peter", email="peter@example.com")
assert user_a != user_c

Aggregate Root

Entity를 확장하여 도메인 이벤트 발행 기능을 갖춘 루트 엔티티입니다.

from uuid import UUID, uuid4

from spakky.core.common.mutability import mutable, immutable
from spakky.domain.models.aggregate_root import AbstractAggregateRoot
from spakky.domain.models.event import AbstractDomainEvent

@mutable
class Order(AbstractAggregateRoot[UUID]):
    customer_name: str
    total_amount: float
    items: list[str]

    def validate(self) -> None:
        if self.total_amount <= 0:
            raise ValueError("주문 금액은 0보다 커야 합니다")

    # 내부 도메인 이벤트 정의
    @immutable
    class Created(AbstractDomainEvent):
        order_id: UUID
        total_amount: float

    @immutable
    class ItemAdded(AbstractDomainEvent):
        order_id: UUID
        item_name: str

    @classmethod
    def next_id(cls) -> UUID:
        return uuid4()

    @classmethod
    def create(cls, customer_name: str, total_amount: float) -> "Order":
        order = cls(
            uid=cls.next_id(),
            customer_name=customer_name,
            total_amount=total_amount,
            items=[],
        )
        # 이벤트 발행
        order.add_event(Order.Created(
            order_id=order.uid,
            total_amount=total_amount,
        ))
        return order

    def add_item(self, item_name: str) -> None:
        self.items.append(item_name)
        self.add_event(Order.ItemAdded(
            order_id=self.uid,
            item_name=item_name,
        ))

# 사용
order = Order.create(customer_name="김철수", total_amount=50000)
order.add_item("노트북")
order.add_item("마우스")

assert len(order.events) == 3  # Created + ItemAdded x2
order.clear_events()  # 처리 후 초기화

Domain Event vs Integration Event

구분 Domain Event Integration Event
범위 같은 바운디드 컨텍스트 내부 바운디드 컨텍스트 간 통신
전달 인메모리 EventMediator EventBus (RabbitMQ, Kafka 등)
기반 클래스 AbstractDomainEvent AbstractIntegrationEvent
from spakky.domain.models.event import AbstractDomainEvent, AbstractIntegrationEvent

# 내부용 — 같은 서비스 내 핸들러가 처리
@immutable
class OrderCreated(AbstractDomainEvent):
    order_id: UUID

# 외부용 — 메시지 브로커를 통해 다른 서비스에 전달
@immutable
class OrderConfirmed(AbstractIntegrationEvent):
    order_id: str
    total_amount: int

CQRS 패턴

Command(명령)와 Query(조회)를 분리합니다.

from spakky.core.stereotype.usecase import UseCase
from spakky.domain.application.command import AbstractCommand, ICommandUseCase
from spakky.domain.application.query import AbstractQuery, IQueryUseCase

# Command — 상태 변경
@immutable
class CreateOrderCommand(AbstractCommand):
    customer_name: str
    total_amount: float

@UseCase()
class CreateOrderUseCase(ICommandUseCase[CreateOrderCommand, Order]):
    def run(self, command: CreateOrderCommand) -> Order:
        return Order.create(
            customer_name=command.customer_name,
            total_amount=command.total_amount,
        )

# Query — 상태 조회
@immutable
class GetOrderQuery(AbstractQuery):
    order_id: UUID

@UseCase()
class GetOrderUseCase(IQueryUseCase[GetOrderQuery, Order]):
    def run(self, query: GetOrderQuery) -> Order:
        return self._repo.find_by_id(query.order_id)