기능적 도메인 기반 설계: 단순화

in kr-dev •  3 years ago 

도메인 주도 설계

Domain-Driven Design은 비즈니스 규칙, 비즈니스 언어 및 비즈니스 문제를 소프트웨어 디자인의 주요 초점으로 초점을 맞춥니다. 주로 아키텍처 계층(예: 데이터베이스, 데이터 전송 개체, 모델, 컨트롤러, 보기)에서 소프트웨어를 설계하는 대신 개별 도메인 또는 제한된 컨텍스트(예: 청구, 인증, 보험 판매, 보험 청구 등)를 사용하여 소프트웨어를 구축하는 데 중점을 둡니다.

DDD는 가장 중요한 변화의 축을 따라 소프트웨어 아키텍처를 정렬합니다. 비즈니스는 도메인에 구현된 새로운 비즈니스 규칙을 구현하기 위해 변경 요청을 생성합니다. 소프트웨어 계층에만 초점을 맞추면 코드를 더 많이 재사용할 수 있지만 재사용은 변화하는 비즈니스 요구 사항에 대응하는 능력을 저해합니다. 판매 및 청구 도메인 모두에서 보험 정책 엔터티를 사용하는 경우 판매 팀을 대신하여 변경하면 청구 팀에 실수로 버그가 생성될 수 있습니다. DDD에서는 별도의 SalesPolicy 엔터티와 PolicyClaim 엔터티를 만들어 코드베이스에서 이 두 비즈니스 도메인을 분리합니다.

Domain-Driven Design은 패턴을 전략적 패턴과 전술적 패턴의 두 가지 범주로 나눕니다. 전략적 패턴은 모든 언어, 프레임워크 및 프로그래밍 패러다임에서 가장 쉽게 재사용할 수 있습니다. 그것들은 소프트웨어 디자인의 보편적인 측면입니다.

그러나 DDD의 전술적 패턴은 객체 지향 프로그래밍 스타일과 밀접하게 결합되어 있으며 DDD의 개념 동안 널리 퍼진 언어의 기능에 영향을 받습니다. 이러한 전술 패턴은 DDD에 기능적 접근 방식을 적용하고 새로운 전술 패턴을 수정, 제거 또는 생성하는 곳입니다.

전술적 DDD 패턴의 기능적 구현으로 이동하기 전에 DDD의 전략적 패턴을 검토합시다. 이러한 개념에 이미 익숙하다면 건너뛰어도 됩니다.

전략적 DDD 패턴

이러한 패턴은 코드와 시스템 아키텍처에 대한 전반적인 접근 방식을 제어합니다. 언어, 프레임워크 또는 패러다임에 관계없이 관련이 있습니다. 이러한 패턴에 대한 정보는 이미 풍부하기 때문에 가장 중요한 전략적 패턴 중 일부만 간략하게 다룰 것입니다.

제한된 컨텍스트

Bounded Context는 비즈니스 용어의 의미가 어디에나 있고 일관된 시스템을 설계할 때의 개념적 경계입니다. 예를 들어, 보험에 정책 엔터티라는 개념이 있을 수 있습니다. 그러나 비즈니스의 다른 팀은 정책에 대해 서로 다른 해석을 합니다. 즉, 영업 팀, 청구 팀 및 보험 계리 팀은 모두 정책 엔터티에 다른 의미를 할당합니다.

디자인에서 제한된 컨텍스트를 생성함으로써 각 도메인을 서로 효과적으로 분리할 수 있습니다. 나는 이 기사 전체에서 경계 컨텍스트(컨텍스트)와 도메인이라는 용어를 같은 의미로 사용합니다.

컨텍스트 맵

컨텍스트 매핑은 시스템에서 컨텍스트/도메인을 식별, 이해 및 전달하는 방법입니다. 컨텍스트 매핑에서 공통 엔티티를 식별하는 경우가 많습니다. DDD의 중요한 원칙은 컨텍스트 간에 공통 엔티티를 제거하거나 공유하지 않는다는 것입니다. 대신 각 컨텍스트가 해당 도메인 내에서 데이터의 구현 및 버전을 유지하도록 허용합니다.

제한된 contexts.excalidraw.png

반부패 계층

위의 다이어그램에서 "클레임 컨텍스트"는 수신 정책 데이터를 "클레임 컨텍스트"에 의미 있는 형식 및 구조로 조정하기 위해 부패 방지 계층을 구현합니다. 이 패턴은 업스트림 엔터티의 세부 정보가 다운스트림 컨텍스트로 누출되는 것을 방지하고 업스트림 변경에 대한 보호 기능을 추가합니다.

OOP DDD의 기능적 단점

Classical DDD의 대부분은 올바른 엔터티, 집계 루트, 도메인 서비스 또는 값 개체에 동작을 올바르게 할당하는 방법을 나타냅니다. 많은 규칙은 DDD 실무자가 구현 시 다양한 클래스 간에 동작을 올바르게 배포하는 데 자신감을 갖도록 하는 것을 목표로 합니다.

그러나 fDDD ( Functional Domain Driven Design )는 데이터와 동작이 양방향이 아닌 단방향으로 연결되기 때문에 이러한 문제를 공유하지 않습니다. 즉, 함수형 프로그래밍의 엔터티는 고유한 동작을 가질 수 없으며 대신 매개 변수 유형의 형태로 데이터에 함수를 연결합니다.

여러 함수가 엔터티를 사용할 수 있지만 함수는 유형 서명과 일치하는 엔터티만 사용할 수 있습니다.

fDDD에서 포기한 Classical DDD 개념을 살펴보겠습니다.

  • 집계 루트: fDDD에서 트랜잭션은 컨트롤러에 바인딩됩니다.
  • 값 개체: fDDD에서 값은 리터럴 값일 뿐입니다.
  • 서비스: fDDD에서 컨트롤러와 가장 밀접한 관련이 있습니다.
  • 도메인 서비스
  • 공장

기능적 DDD 전술 패턴

함수형 프로그래밍 패러다임 내에서 DDD의 전술적 패턴의 실제적인 구현을 고려할 때입니다. 이러한 패턴에는 기능적 핵심과 명령적 쉘이라는 두 개의 계층이 있습니다.

Functional Core는 Pure Functions만을 사용하여 전술적 패턴을 구현합니다. 이 계층은 Pure Functions가 가장 높은 수준의 예측 가능성, 신뢰성, 테스트 가능성 및 변경 가능성을 제공하기 때문에 대부분의 비즈니스 규칙을 구현하는 곳입니다.

Imperative Shell에서 우리는 시스템과 비즈니스 규칙 간의 조정에 도움이 되는 전술적 패턴을 사용하려고 합니다. 예를 들어 명령형 쉘의 코드는 데이터베이스에서 필요한 데이터를 검색하고, 불변량을 확인하고, 변경 사항을 데이터베이스에 다시 삽입하고, 이메일을 발송하는 역할을 할 수 있습니다.

기능적 DDD Patterns.excalidraw.png

Imperative Shell과 Functional Core 모두에 걸쳐 있는 엔티티의 Functional 구현을 처음부터 시작하겠습니다.

엔티티

fDDD에서는 일반적으로 도메인 엔터티를 유형 정의로 구현합니다. 예를 들어 다음과 같이 Sales 도메인에서 정책을 정의할 수 있습니다.

type Policy = {
  id: number;
  customer: Customer;
  salesPerson: Staff;
  createdAt: Date;
}

엔티티를 정의하는 이 접근 방식은 간단하지만 곧 다룰 부패 방지 계층이나 저장소에서 다양한 어댑터를 구현하는 것을 암시합니다.

유용한 예는 네트워크를 통해 수신할 때 이 엔터티를 구문 분석하는 접근 방식일 수 있습니다. 이 경우 다음과 같은 파서 기능을 구현할 수 있습니다.

type PolicyParser = (rawData: unknown) => Policy | ParseError;

이 구문 분석 기능은 정책 엔터티를 도메인 계층으로 가져오기 위한 형식이 안전한 런타임 구현을 만듭니다. 이러한 파서를 손으로 구현하는 대신 zod 와 같은 런타임 타이핑 패키지를 사용하는 경우가 많습니다 .

불변: 기능적 핵심

Invariants는 하나의 작업만 있는 Pure Functions입니다. 제공된 엔터티가 주어진 비즈니스 규칙을 충족하는지 확인하십시오. 주소 엔터티의 경우 제공된 전화 번호에 주소 영역과 일치하는 지역 코드가 있는지 확인하는 불변량을 작성할 수 있습니다.

export const validatePhoneMatchesCountry = (address: Address): boolean => {
  if (address.phoneNumber ?? false) {
    return false;
  }
  return getCountryFromAreacode(address.phoneNumber) === address.country;
}

보시다시피, 불변 함수는 작고 테스트하기 매우 쉽습니다.

우리는 주로 Derivers 내부에서 Invariants를 구성하여 Invariants를 사용합니다. 또한 많은 파생자가 불변의 정확한 구성을 공유하는 경우 다른 불변으로 구성된 불변을 조심스럽게 만들 수도 있습니다.

제공자: 기능적 핵심

createAddress파생 상품은 또는 와 같은 특정 작업을 지원하기 위해 생성된 순수 함수입니다 cancelSubscription. 파생자를 호출할 때 변경에 대한 델타를 파생하거나 실패 원인을 나타내는 결과를 반환하는 데 필요한 모든 정보를 전달해야 합니다.

type UpgradeSubscriptionOutcome = UpgradeSucceeded 
  | AccountOverdrawn 
  | InvalidSubscriptionStatus;

export const deriveUpgradeSubscriptionOutcome = (
  newPlanLevel: Subscription['planLevel'],
  subscription: Subscription, 
  customer: Customer,
  upgradePriceMap: PriceMap
): UpgradeSubscriptionOutcome => {
  if (!validateCustomerBalance(customer)) {
    return {
      outcome: 'ACCOUNT_OVERDRAWN',
      payload: { balanceOwing: customer.balance },
    };
  if (subscription.status !== 'ACTIVE') {
    return {
      outcome: 'INVALID_SUBSCRIPTION_STATUS',
      payload: { 
        currentStatus: subscription.status, 
        expectedStatus: 'ACTIVE' 
      },
    };
  }
  const prorataDays = calculateProrata(subscription);
  const upgradeFee = upgradePriceMap[subscription.planLevel][newPlanLevel];
  return {
    outcome: 'SUCCEEDED',
    payload: {
      prorataDays,
      upgradeFee,
    },
  };
}

위의 Deriver에서 두 가지 잠재적인 실패 사례를 확인한 다음 요청된 업그레이드에 필요한 변경 사항을 계산합니다. 이 함수에서 수행하지 않는 작업을 고려하십시오.

  • 데이터베이스에서 고객의 세부 정보를 가져오지 않음
  • 고객에게 이메일로 영수증을 보내지 않음
  • 신용 카드 청구를 위해 결제 제공업체에 전화하지 않음
  • 데이터베이스에 변경 사항을 저장하지 않음

이 작업의 핵심 비즈니스 규칙은 단일 기능으로 압축되어 모든 비즈니스 규칙을 테스트할 수 있습니다. 이 기능의 범위를 좁게 유지함으로써 기능적 순수성을 유지할 수 있습니다. 이 제한된 범위는 이 기능에 대한 단위 테스트를 덜 복잡하게 만들고 테스트 자체를 더 안정적으로 만듭니다. 비결정론, 데이터베이스 고정 장치, 스텁 또는 모의에 대한 기회가 없습니다.

컨트롤러: 명령형 쉘

컨트롤러를 사용하여 작업 중에 시스템의 비동기 부분을 조정하고 Deriver를 호출합니다. 위의 예에서 컨트롤러는 파생 제품의 범위를 벗어난 것으로 식별된 각 단계를 담당합니다.

export const upgradeSubscription = async (
  customerId: Customer['id'], 
  newPlanLevel: Subscription['planLevel']
): Promise<UpgradeSubscriptionOutcome> => {
  const [customer, subscription] = await Promise.all([
    customerRepo.getById(customerId),
    subscriptionRepo.getByCustomerId(customerId),
  ]);

  const { outcome, payload } = deriveUpgradeSubscriptionOutcome(
    newPlanLevel,
    subscription,
    customer,
    UPGRADE_PRICE_MAP,
  );

  switch (outcome) {
    case 'INVALID_SUBSCRIPTION_STATUS': {
      return { outcome, payload };
    }
    case 'ACCOUNT_OVERDRAWN': {
      await sendRepaymentReminder(customer, payload.balanceOwing);
      return { outcome, payload };
    }
    case 'SUCCEEDED': {
      const updatedSubscription = await subscriptionRepo.update({ 
        ...subscription,
        prorataDays: payload.prorataDays,
        planLevel: newPlanLevel,
      });
      const transactionId = await chargeCreditCard(customer.defaultCard);
      await sendInvoice(
        customer, 
        payload.upgradeFee, 
        updatedSubscription,
        transactionId
      );
      return {
        outcome,
        payload: {
          ...payload,
          transactionId,
        }
      }
    }
    default: {
      isNever(outcome);
      break;
    }
  }
}

위의 함수에서 볼 수 있듯이 지금 우리는 주로 시스템의 비동기 부분을 다루고 있습니다. 이러한 컨트롤러 기능에서 우리는 가능한 한 적은 비즈니스 로직을 원합니다. 위의 예는 특히 복잡합니다. 종종 컨트롤러는 데이터베이스에서 데이터를 검색하고 성공 시 저장합니다.

그러나 컨트롤러는 어떠한 비즈니스 결정도 내리지 않습니다. Deriver의 결과에 따라 조치만 취합니다. 구독의 유효성을 검사하지 않으며 어떤 규칙도 확인하지 않습니다.

그러나 이메일을 보내거나 신용 카드로 청구하는 것과 같은 특정 상황에서 취하는 조치는 테스트하려는 것일 수 있습니다. 이 경우 Partially Applied Controller 패턴을 사용할 수 있습니다.

부분적으로 적용된 컨트롤러를 통한 테스트

이전 예제를 더 테스트 가능하게 만들고 싶다면 객체 지향 프로그래밍에서 종속성 주입을 사용할 수 있는 것처럼 부분 함수를 유사하게 사용할 수 있습니다. 예를 살펴보겠습니다.

export const createUpgradeSubscriptionController = (
  customerRepo: CustomerRepository,
  subscriptionRepo: SubscriptionRepository,
  sendRepaymentReminder: (customer: Customer, balanceOwing: number) => Promise<void>,
  chargeCreditCard: (card: CreditCard) => Promise<Transaction['id']>,
  sendInvoice: InvoiceSender,
) => async (
  customerId: Customer['id'], 
  newPlanLevel: Subscription['planLevel']
): Promise<UpgradeSubscriptionOutcome> => {
  /* Remaining implementation is identical to the previous example */
};

// In an adjacent test file
const fakeSendRepaymentReminder = sinon.fake.resolves();
const fakeChargeCreditCard = sinon.fake.resolves('1a2bc');
const fakeSendInvoice = sinon.fake.resolves();
const upgradeSubscriptionTestController = createUpgradeSubscriptionController(
  customerRepoInMemory,
  subscriptionRepoInMemory,
  fakeSendRepaymentReminder,
  fakeChargeCreditCard,
  fakeSendInvoice,
);

이제 customerRepo& 의 테스트 구현을 subscriptionRepo제공하는 동시에 다른 기능에 대한 가짜도 제공할 수 있습니다. 이것은 훨씬 더 복잡한 테스트이기 때문에 Deriver의 비즈니스 규칙 테스트를 반복하고 싶지 않습니다. 대신에 우리는 컨트롤러가 Deriver의 세 가지 가능한 시나리오 각각에 대해 호출하는 기능만을 주장하기를 원합니다.

저장소: Imperative Shell

CRUD 기반으로 엔터티를 검색하기 위한 리포지토리 패턴은 DDD 패턴만이 아닙니다. 기능적 패러다임과 OO 패러다임 간에 크게 변경된 패턴도 아닙니다. fDDD를 위해 리포지토리에 대해 염두에 두어야 할 몇 가지 제약 조건만 언급하겠습니다.

  • 데이터베이스 문제를 리포지토리 계층으로 완전히 제한하십시오.

  • 저장소 계층으로 들어오고 나가는 모든 데이터 유형을 구문 분석, 변환 또는 매핑합니다.

    일반 리포지토리 유형은 다음과 같습니다.

type Repository<T> = {
   getMatching: (query: Query<T>) => Promise<T[]>;
   getById: (id: string) => Promise<T | undefined>;
   create: (item: Omit<T, 'id'>) => Promise<T>;
   update: (item: T) => Promise<T | undefined>;
   upsert: (item: T) => Promise<T>;
   delete: (id: string) => Promise<undefined | MissingEntityError>;
 };

type Query<T> = {
  [k: keyof T]: { operator: Operator, value: unknown },
}

함수 구현 create은 다음과 같습니다.

const subscriptionRepo: Repository<Subscription> = {
  create: async (subscription) => {
    const objKeys = Object.keys(subscription);
    const result = await pool.query<T>(
      `
      INSERT INTO "subscriptions"
      (`${objKeys.join(',')}`)
      VALUES (`${objKeys.map((_, i) => `$${i+1}`).join(',')}`)
      RETURNING *
      `,
      Object.values(subscription)
    );
    return result.rows.map(parseSubscriptionFromDb)[0];
  },
  // remaining repo functions omitted
};

위의 예에서 저장소는 함수를 사용하여 데이터베이스 유형에서 도메인 엔티티 유형으로 다시 매핑합니다 parseSubscriptionFromDb. 이 매핑은 데이터베이스와 구현 간의 분리 계층을 유지하는 데 도움이 됩니다. 또한 데이터베이스가 예기치 않은 데이터를 반환하는 경우 오류를 발생시키면서 유형을 더 좁힐 수 있습니다.

소프트웨어 아키텍처 컨텍스트

이제 fDDD 구현의 패턴을 정의했으므로 전체 소프트웨어 아키텍처에서 fDDD가 차지하는 위치를 고려해야 합니다. 일반적인 Express.js 애플리케이션에서 fDDD를 구현하려면 다음과 같이 구현할 수 있습니다.

FDDD 응용 소프트웨어 Architecture.excalidraw.png

POST 요청에 대한 경로 핸들러 /api/subscriptions는 다음과 같을 수 있습니다.

export const handleSubscriptionPost: RequestHandler = async (req, res) => {
  const customerId = req.jwt.customerId;
  const { subscriptionLevel, paymentToken } = req.body;

  const { outcome, payload } = await createSubscription(
    customerId, 
    subscriptionLevel, 
    paymentToken
  );

  switch (outcome) {
    case 'PAYMENT_FAILED': {
      res.status(400);
      break;
    }
    case 'SUCCEEDED': {
      res.status(201);
      break;
    }
  }
  res.json({ outcome, payload });
};

우리의 모든 라우트 핸들러는 다음을 담당합니다.

  • 컨트롤러에서 사용할 HTTP 요청의 데이터 준비
  • 결과를 HTTP 상태 코드로 다시 매핑

라우트 핸들러는 비즈니스 규칙에 대한 의견이 없으며 도메인 코드는 콘텐츠 유형이나 상태 코드에 대한 지식이 없습니다.

코드베이스의 구조를 계획할 때 기본적으로 파일과 폴더의 기반으로 선택할 수 있는 두 가지 차원이 있습니다.

  • 애플리케이션 계층(예: 데이터베이스, 경로 처리기, 도메인, 서비스)
  • 작업(예: createSubscription, updateUser등)

전자는 특정 영역에 대한 모든 코드를 쉽게 찾을 수 있다는 장점이 있지만 변경 사항을 구현하려면 많은 폴더를 열어야 한다는 단점이 있습니다. 후자는 주어진 변경 사항에 대한 모든 관련 코드가 근처에 있을 가능성이 높지만 유사하게 작동하거나 변경 사항의 영향을 받을 수 있는 다른 코드를 찾기가 더 어렵다는 이점이 있습니다.

도메인 서비스 에 대한 코드베이스를 구성할 때 모든 도메인 코드를 가능한 한 같은 위치에 유지하는 것이 좋습니다.

이 기본 설정은 최상위 폴더 구조가 다음과 같은 행을 따른다는 것을 의미합니다.

최상위 폴더 구조(와이드).excalidraw-2.png

그러나 도메인 폴더 내의 "엔티티" 및 "작업" 하위 폴더로 분할합니다.

도메인 폴더 구조(와이드).excalidraw.png

이 구조는 각 작업에 대해 다른 파생 항목에서 엔터티의 불변량을 재사용할 가능성을 높입니다.

마무리

이 기사가 기능적 프로그래밍 패러다임 내에서 도메인 주도 설계를 구현하고 복잡한 소프트웨어를 보다 쉽게 관리할 수 있도록 성공적으로 만드는 방법을 보여주길 바랍니다. 보시다시피, fDDD는 고전적인 DDD 구현에서 많은 복잡한 요소를 단순화합니다. 다음은 우리가 배운 내용에 대한 간략한 복습입니다.

  • 엔터티는 최소한 유형 정의이며 런타임 유효성 검사를 위한 파서를 포함할 수 있습니다.
  • 불변은 엔터티를 입력 매개 변수로 사용하고 간단한 비즈니스 규칙에 대해 유효성을 검사하고 부울을 반환합니다.
  • 파생자는 하나 이상의 엔터티를 입력 매개변수로 사용하고, 작업별 비즈니스 규칙에 대해 유효성을 검사하고, 관련 불변량을 구성하고, 잠재적인 결과의 구별된 조합을 반환합니다.
  • 컨트롤러는 Derivers를 대신하여 데이터베이스를 포함한 시스템의 비동기 부분을 조정합니다.
  • 더 넓은 애플리케이션은 컨트롤러를 통해 엄격하게 도메인 계층에 액세스합니다.

출처 : https://antman-does-software.com/functional-domain-driven-design-simplified

Authors get paid when people like you upvote their post.
If you enjoyed what you read here, create your account today and start earning FREE STEEM!
Sort Order:  

[광고] STEEM 개발자 커뮤니티에 참여 하시면, 다양한 혜택을 받을 수 있습니다.