TypeScript에서 의미 있는 도메인 모델

발행: (2026년 3월 26일 PM 10:23 GMT+9)
8 분 소요
원문: Dev.to

Source: Dev.to

번역할 텍스트를 제공해 주시면, 요청하신 대로 한국어로 번역해 드리겠습니다. (코드 블록, URL 및 마크다운 형식은 그대로 유지됩니다.)

Most bugs I’ve seen in production weren’t caused by wrong algorithms or bad infrastructure.

They were caused by invalid state — an order with no items, a paid order without an invoice ID, a payment that spent more than expected.

The code was syntactically correct. TypeScript was happy. But the business rules were silently violated.

This post explores a few patterns that help you build domain models that make invalid states impossible — and when something does go wrong, make failures explicit.


내가 프로덕션에서 본 대부분의 버그는 잘못된 알고리즘이나 열악한 인프라 때문이 아니었습니다.

그 원인은 잘못된 상태였으며 — 아이템이 없는 주문, 청구서 ID 없이 결제된 주문, 예상보다 더 많이 사용된 결제 등입니다.

코드는 구문적으로는 올바랐습니다. TypeScript도 만족했습니다. 하지만 비즈니스 규칙이 조용히 위배되었습니다.

이 글에서는 잘못된 상태를 불가능하게 만들고, 문제가 발생했을 때 실패를 명시적으로 만들 수 있는 도메인 모델을 구축하는 몇 가지 패턴을 살펴봅니다.

암시적 규칙의 문제점

전형적인 주문 엔티티를 고려해 보세요:

class Order {
  status: "DRAFT" | "PLACED" | "PAID";
  items: OrderItem[];
  invoiceId?: string;
}

invoiceId가 없는 PAID 주문을 만드는 것을 막는 것은 아무것도 없습니다.
마지막 아이템을 제거하는 것을 막는 것도 없습니다. 규칙은 여러분의 머리 속(또는 어딘가의 주석)에 있을 뿐, 코드에는 없습니다.

검증을 한 곳—예를 들어 유스케이스나 컨트롤러—에 추가할 수 있지만, 다른 개발자(또는 미래의 당신)가 다른 경로에서 상태를 변형하고 이를 완전히 우회하는 것을 막는 것은 없습니다.

불변식: 항상 참인 규칙

불변식(invariant) 은 특정 연산 이후가 아니라 언제나 유지되어야 하는 비즈니스 규칙입니다.

한 곳에서만 검증하고 다른 코드가 상태를 건드리지 않을 것이라고 기대하는 대신, 규칙을 한 번 선언하면 상태를 읽을 때마다 확인됩니다:

// 이 규칙은 상태가 어떻게 변형되었든 항상 적용됩니다
const paidOrderHasInvoiceId = new BaseDomainInvariant(
  "Paid Order Has Invoice Id",
  (state) => {
    if (state.status === "PAID") {
      return state.invoiceId !== undefined;
    }
    return true;
  }
);

엔티티는 생성자에서 자신의 불변식을 등록합니다:

class Order extends DomainEntity {
  private constructor(id: string, state: OrderState) {
    super(id, state);
    this.addInvariant(orderHasAtLeastOneItem);
    this.addInvariant(paidOrderHasInvoiceId);
  }
}

order.readState() 를 호출할 때마다 불변식이 검사됩니다. 어느 하나라도 위배되면 "Corrupted state detected" 라는 오류가 발생합니다. 깨진 엔티티를 조용히 읽는 일은 절대 없습니다.

불변식은 조합해서 사용할 수도 있습니다:

const complexRule = invariantA.and(invariantB).or(invariantC);

Result 패턴을 사용한 명시적 실패

TypeScript는 throw를 어디서든, 무엇이든 할 수 있게 해줍니다. 하지만 throw는 함수 시그니처에 드러나지 않으므로, 호출자는 구현을 읽어보지 않으면(또는 프로덕션에서 발견하지 않으면) 어떤 오류가 발생할 수 있는지 알 수 없습니다.

Result 패턴은 실패를 일급 반환 값으로 만들어 줍니다:

lockCredits(params: { amount: number }): Result {
  if (this.state.subCreditBalance  {
  constructor(entityId: string, payload: { invoiceId: string }) {
    super({ name: "ORDER_PAID", version: 1, entityId, payload });
  }
}

Operations return their event:

pay(params: { invoiceId: string }): Result {
  if (this.state.status !== "PLACED") {
    return err(new InvalidStatusTransition(/* … */));
  }
  this.state.status = "PAID";
  this.state.invoiceId = params.invoiceId;
  return ok(new OrderPaid(this.id(), { invoiceId: params.invoiceId }));
}

엔티티 상태와 이벤트를 원자적으로 저장합니다:

const result = order.pay({ invoiceId: "INV-123" });
if (result.isOk()) {
  await repository.saveWithEvents(order, result.value);
}

이렇게 하면 별도의 노력 없이 전체 감사 로그를 얻을 수 있습니다. 이벤트는 또한 부수 효과(이메일, 웹훅, 프로젝션)를 분리된 방식으로 트리거하는 데 매우 유용합니다.


전체를 합쳐 보기: 사용 사례

다음은 이러한 모든 패턴을 조합한 완전한 payOrder 사용 사례입니다:

export async function payOrderUseCase(
  repository: OrderRepository,
  id: string,
  invoiceId: string,
): Promise> {

  const getResult = await repository.getById(id);
  if (getResult.isErr()) throw getResult.error; // infra error, not domain

  const order = getResult.value;
  if (order === undefined) {
    return err(new EntityNotFound("Order not found", { entityId: id }));
  }

  const payResult = order.pay({ invoiceId });
  if (payResult.isErr()) return err(payResult.error);

  const saveResult = await repository.saveWithEvents(order, payResult.value);
  return saveResult;
}

불변 조건, Result 패턴, 그리고 도메인 이벤트를 결합하면 다음을 얻을 수 있습니다:

  • 불가능한 잘못된 상태 – 불변 조건은 모든 읽기 시점에 엔티티를 보호합니다.
  • 명시적인 실패 처리 – 호출자는 Result 결과를 처리해야 합니다.
  • 감사 가능하고 불변의 히스토리 – 모든 변경이 이벤트로 캡처됩니다.
if (saveResult.isErr()) throw saveResult.error;

return ok(order.readState());

실패 처리

각 종류의 실패가 다르게 처리되는 방식을 확인하세요:

  • 인프라 오류 (예: DB 다운): throw — 이는 예외적인 상황이며 도메인 로직이 아닙니다.
  • 도메인 실패 (예: 잘못된 상태): 타입이 지정된 Result 로 반환됩니다 — 호출자가 이를 처리합니다.

라이브러리

All of these patterns are available in Ontologic — a small TypeScript toolkit that provides:

  • DomainEntity
  • BaseDomainInvariant
  • DomainEvent
  • DomainError
  • Result
  • Repository interface
npm install ontologic

It doesn’t try to be a framework — no decorators, no magic, no IoC container required. Just plain TypeScript classes and types that you can drop into any project.

Full docs and examples (including the Order and CreditBalance aggregates shown above) are at ontologic.site.

These patterns aren’t new — they come from Domain‑Driven Design and functional programming. But they’re underused in TypeScript codebases, where it’s easy to reach for throw and optional fields instead.

The payoff is code that reads like business rules, fails loudly when something is wrong, and makes it hard to put entities into states that shouldn’t exist.

0 조회
Back to Blog

관련 글

더 보기 »