본문 바로가기

BackEnd

설계를 깔끔하게 발전시키기

반응형

https://pragprog.com/titles/swdddf/domain-modeling-made-functional/

 

Domain Modeling Made Functional

Use domain-driven design to effectively model your business domain, and implement that model with F#.

pragprog.com

해당 책의 마지막 장입니다(13)

TLDR : 초이스 타입을 이용해 관심사가 유사한 필드를 하나의 차원으로 통합하라!

 
도메인 모델의 구현이 끝이 아닙니다.
요구 사항이 변경되면 모델이 지저분해지고 다양한 하위 시스템이 얽혀 테스트하기 어려워집니다.
이제 마지막 과제가 있습니다. 시스템에 영양을 최소화하면서 설계가 발전할 수 있을까요?
 
도메인 주도 설계는 한 번에 끝나는 정적 프로세스가 아닙니다.
개발자, 도메인 전문가 및 기타 이해 관계자 간의 지속적인 협업을 의미합니다.
따라서 요구 사항이 변경되면 구현을 패치하는 것보다 항상 먼저 도메인 모델을 재평가하여 시작해야 합니다.
 
요구 사항에 대한 여러 가능한 변경 사항을 살펴보고 구현을 변경하기 전에 먼저 도메인 모델에 대한 이해에 영향을 미치는지 확인하기 위해 이를 추적합니다. 또한 디자인에서 타입을 많이 사용한다는 것은 모델이 변경될 때 코드가 실수로 손상되지 않는다는 높은 확신을 가질 수 있음을 의미한다는 것을 알 수 있습니다.
 
 
4가지 종류의 변경 사항을 살펴보겠습니다.
 
  • 워크플로에 새 단계 추가
  • 워크플로에 대한 입력 변경
  • 키 도메인 타입(Order) 정의 변경에 의한  파급 효과 확인
  • 비즈니스 규칙을 준수하도록 전체 워크플로 변환

Change 1: Adding Shipping Charges (배송 요금 추가하기)

첫 번째 요구 사항 변경을 위해 배송 및 배송 비용을 계산하는 방법을 살펴보겠습니다.

회사에서 특별한 계산을 사용하여 고객에게 배송료를 청구하려고 한다고 가정해 보겠습니다.

이 새로운 요구 사항을 어떻게 통합할 수 있습니까?

 

먼저 배송비를 계산하는 함수가 필요합니다. 이 회사가 캘리포니아에 기반을 두고 있으므로 지역으로의 배송은 하나의 가격(예: $5)이고, 원격 지역으로의 배송은 다른 가격(예: $10)이며, 다른 국가로의 배송은 또 다른 가격(예: $20)입니다.
이 계산을 구현하는 첫 번째 단계는 다음과 같습니다.
​ 	​/// Calculate the shipping cost for an order​
​ 	​let​ calculateShippingCost validatedOrder =
​ 	  ​let​ shippingAddress = validatedOrder.ShippingAddress
​ 	  ​if​ shippingAddress.Country = ​"US"​ ​then​
​ 	    ​// shipping inside USA​
​ 	    ​match​ shippingAddress.State ​with​
​ 	    | ​"CA"​ | ​"OR"​ | ​"AZ"​ | ​"NV"​ ->
​ 	      5.0 ​//local​
​ 	    | _ ->
​ 	      10.0 ​//remote​
​ 	  ​else​
​ 	    ​// shipping outside USA​
​ 	    20.0
불행히도 특수 조건에 대해 여러 분기를 사용하는 이러한 종류의 조건부 논리는 이해하고 유지하기 어렵습니다.
(중간에 명시적으로 범주화 스테이트를 하나 추가해줘서 해결합니다.)

Using Active Patterns to Simplify Business Logic (비즈니스 로직 단순화를 위한 액티브 패턴 사용)

로직을 유지 관리하기 쉽게 만드는 한 가지 솔루션은 실제 가격 논리에서 도메인 중심의 "분류"를 분리하는 것입니다.
F#에는 각 초이스 타입에 대해 구별된 공용체 타입 (Discriminated Union Type)을 명시적으로 정의한 것처럼
조건부 논리를 패턴 일치가 가능한 명명된 초이스 항목 집합으로 바꾸는 데 사용할 수 있는 활성 패턴이라는 기능이 있습니다.
활성 패턴은 이러한 종류의 분류에 적합합니다.
이 요구 사항에 대해 활성 패턴 접근 방식을 사용하려면 먼저 각 배송 범주와 일치하는 패턴 집합을 정의합니다.
​ 	​let​ (|UsLocalState|UsRemoteState|International|) address =
​ 	  ​if​ address.Country = ​"US"​ ​then​
​ 	    ​match​ address.State ​with​
​ 	    | ​"CA"​ | ​"OR"​ | ​"AZ"​ | ​"NV"​ ->
​ 	      UsLocalState
​ 	    | _ ->
​ 	      UsRemoteState
​ 	  ​else​
​ 	    International
그런 다음 배송 계산 자체에서 다음 범주에 대해 패턴 일치를 수행할 수 있습니다.
​ 	​let​ calculateShippingCost validatedOrder =
​ 	  ​match​ validatedOrder.ShippingAddress ​with​
​ 	  | UsLocalState -> 5.0
​ 	  | UsRemoteState -> 10.0
​ 	  | International -> 20.0
이와 같이 비즈니스 로직에서 분류를 분리하면 코드가 훨씬 명확해지고 활성 패턴 케이스의 이름도 문서 역할을 합니다.
물론 활성 패턴 자체를 정의하는 것은 여전히 ​​복잡하지만 해당 코드는 비즈니스 로직 없이 범주화만 수행합니다.
범주화 논리가 변경되는 경우(예: "UsLocalState"에 다른 상태가 있는 경우) 가격 책정 기능이 아닌 활성 패턴만 변경하면 됩니다. 우려 사항을 잘 구분했습니다.
 

Creating a New Stage in the Workflow (워크플로에 새 단계 추가)

다음으로 주문 배치 워크플로에서 이 배송 비용 계산을 사용해야 합니다.
한 가지 옵션은 가격 책정 단계를 수정하고 여기에 배송비 논리를 추가하는 것입니다.
그러나 우리는 작동하는 코드를 변경하고 더 복잡하게 만들며, 이는 차례로 버그로 이어질 수 있습니다.
안정적인 코드를 변경하는 대신 합성을 최대한 활용하고 워크플로에 새 단계를 추가하여 계산을 수행하고 PricedOrder를 업데이트하겠습니다.
​type​ AddShippingInfoToOrder = PricedOrder -> PricedOrderWithShippingInfo
워크플로의 이 새 단계는 PriceOrder와 다음 단계인 AcknowledgeOrder 사이에 배치될 수 있습니다.

일반적으로 디자인을 발전시키면 추적해야 하는 세부 정보가 더 많이 발견됩니다.
예를 들어, 고객은 배송 방법(예: FedEx 또는 UPS)과 가격을 알고 싶어할 수 있습니다(심지어 너무 단순할 수도 있음).
따라서 이 추가 정보를 캡처하려면 몇 가지 새로운 유형이 필요합니다.
​ 	​type​ ShippingMethod =
​ 	  | PostalService
​ 	  | Fedex24
​ 	  | Fedex48
​ 	  | Ups48
​ 	
​ 	​type​ ShippingInfo = {
​ 	  ShippingMethod : ShippingMethod
​ 	  ShippingCost : Price
​ 	  }
​ 	
​ 	​type​ PricedOrderWithShippingMethod = {
​ 	  ShippingInfo : ShippingInfo
​ 	  PricedOrder : PricedOrder
​ 	  }
이제 새로운 배송 정보가 포함된 다른 주문 타입인 PricedOrderWithShippingInfo를 만들었습니다.
이것은 과잉이라고 생각할 수 있으며 ShippingInfo에 대한 필드를 추가하여 PricedOrder 타입을 재사용하는 것을 고려할 수 있습니다.
그러나 완전히 새로운 타입을 만들면 몇 가지 이점이 있습니다.
 
  • PricedOrderWithShippingInfo를 입력으로 예상하도록 AcknowledgeOrder 단계를 수정하면 단계의 순서를 잘못 가져올 수 없습니다.
  • PricedOrder의 필드로 ShippingInfo를 추가하면 배송 계산이 완료되기 전에 무엇으로 초기화해야 합니까? 단순히 기본값으로 초기화하면 버그가 발생할 수 있습니다.

마지막 문제: 배송 비용을 주문에 어떻게 저장해야 합니까? 다음과 같이 헤더의 필드여야 합니까?

​ 	​type​ PricedOrder = {
​ 	  ...
​ 	  ShippingInfo : ShippingInfo
​ 	  OrderTotal : Price
​ 	  }
아니면 이와 같은 새로운 종류의 주문 라인이어야 합니까?
​ 	​type​ PricedOrderLine =
​ 	  | Product ​of​ PricedOrderProductLine
​ 	  | ShippingInfo ​of​ ShippingInfo

 

두 번째 접근 방식은 헤더의 필드를 포함하는 데 특별한 로직이 필요하지 않고 주문 합계가 항상 행의 합계에서 계산될 수 있음을 의미합니다.
단점은 실수로 두 개의 ShippingInfo 라인을 생성할 수 있고 올바른 순서로 라인을 인쇄하는 것에 대해 걱정해야 한다는 것입니다.

헤더에 배송 정보를 저장해 보겠습니다.

이제 워크플로에서 AddShippingInfoToOrder 단계를 완료하는 데 필요한 모든 것이 준비되었습니다.
다음 요구 사항을 따르는 함수를 코딩하기만 하면 됩니다.
  • AddShippingInfoToOrder 함수 타입을 구현합니다.
  • 배송 비용을 계산하려면 종속성이 필요합니다.
  • 배송비를 받아 PricedOrder에 추가하여 PricedOrderWithShippingInfo를 만듭니다.

이러한 모든 요구 사항이 타입으로 표시되기 때문에 잘못된 구현을 만드는 것은 놀랍게도 어렵습니다!

 

1. 종속성 구현 (addShippingInfoToOrder)

​ 	​let​ addShippingInfoToOrder calculateShippingCost : AddShippingInfoToOrder  =
​ 	  ​fun​ pricedOrder ->
​ 	    ​// create the shipping info​
​ 	    ​let​ shippingInfo = {
​ 	      ShippingMethod = ...
​ 	      ShippingCost = calculateShippingCost pricedOrder
​ 	      }
​ 	
​ 	    ​// add it to the order​
​ 	    {
​ 	    OrderId = pricedOrder.OrderId
​ 	    ...
​ 	    ShippingInfo = shippingInfo
​ 	    }

 

2. 워크플로 함수 최상위에 삽입
​ 	​// set up local versions of the pipeline stages​
​ 	​// using partial application to bake in the dependencies​
​ 	​let​ validateOrder unvalidatedOrder = ...
​ 	​let​ priceOrder validatedOrder = ...
​ 	​let​ addShippingInfo = addShippingInfoToOrder calculateShippingCost
​ 	
​ 	​// compose the pipeline from the new one-parameter functions​
​ 	unvalidatedOrder
​ 	|> validateOrder
​ 	|> priceOrder
​ 	|> addShippingInfo
​ 	...

파이프라인에 단계를 추가해야 하는 다른 이유

 
이 예에서는 요구 사항이 변경되었기 때문에 파이프라인에 새 구성 요소를 추가했습니다.
그러나 이와 같은 구성 요소를 추가 및 제거하는 것은 모든 종류의 기능을 추가하는 좋은 방법입니다.
 
한 단계가 다른 단계와 격리되고 필요한 타입을 준수하는 한 안전하게 추가하거나 제거할 수 있습니다. 다음은 이 방법으로 수행할 수 있는 몇 가지 작업입니다.
 
  • 운영 투명성을 위한 단계를 추가하여 파이프라인 내부에서 진행 중인 일을 더 쉽게 볼 수 있습니다. 로깅, 성능 메트릭, 감사 등은 모두 이러한 방식으로 쉽게 추가할 수 있습니다.
  • 권한 부여를 확인하는 단계를 추가할 수 있으며, 실패하면 파이프라인의 나머지 부분을 건너뛰고 실패 경로로 보냅니다.
  • 입력의 컨텍스트(컨피규레이션)을 기반으로 컴포지션 루트에서 동적으로 단계를 추가 및 제거할 수도 있습니다.

Change 2: Adding Support for VIP Customers

이제 워크플로의 전체 입력에 영향을 주는 변경 사항을 살펴보겠습니다. 비즈니스에서 무료 배송 또는 익일 배송으로의 무료 업그레이드와 같은 특별 대우를 받는 VIP 고객을 지원하기를 원한다고 가정해 보겠습니다.
이것을 어떻게 모델링해야 할까요?
우리가 하지 말아야 할 한 가지는 도메인에서 비즈니스 규칙의 출력을 모델링하는 것입니다(예: 주문에 "무료 배송" 플래그 추가).
대신 비즈니스 규칙에 대한 입력("고객은 VIP입니다")을 저장한 다음 비즈니스 규칙이 해당 입력에 대해 작동하도록 해야 합니다.
그렇게 하면 비즈니스 규칙이 변경되더라도(그것도 마찬가지입니다!) 도메인 모델을 변경할 필요가 없습니다.
우리는 어떻게든 고객의 VIP 상태가 웹사이트 로그인과 관련이 있다고 가정하므로 주문 접수 도메인에서 스스로 결정할 필요가 없습니다. 그러나 VIP 상태를 어떻게 모델링해야 할까요? 다음과 같이 CustomerInfo에서 플래그로 모델링해야 합니까?
​ 	​type​ CustomerInfo = {
​ 	  ...
​ 	  IsVip : ​bool​
​ 	  ...
​ 	  }
아니면 다음과 같이 일련의 고객 상태 중 하나로 모델링해야 합니까?
​ 	​type​ CustomerStatus =
​ 	  | Normal ​of​ CustomerInfo
​ 	  | Vip ​of​ CustomerInfo
​ 	
​ 	​type​ Order = {
​ 	  ...
​ 	  CustomerStatus : CustomerStatus
​ 	  ...
​ 	  ​}
이것을 고객 상태로 모델링하는 것의 단점은 신규 고객 대 재방문 고객, 로열티 카드를 보유한 고객 등과 같이 이것과 직교하는 다른 고객 상태가 있을 수 있다는 것입니다.
가장 좋은 접근 방식은 다른 고객 정보와 별개로 "VIP" 차원을 따라 상태를 나타내는 초이스 유형을 사용하는 절충안입니다.​
​ 	​type​ VipStatus =
​ 	  | Normal
​ 	  | Vip
​ 	
​ 	​type​ CustomerInfo = {
​ 	  ...
​ 	  VipStatus : VipStatus
​ 	  ...
​ 	  ​}
다른 종류의 상태가 필요한 경우 같은 방식으로 쉽게 추가할 수 있습니다. 예를 들어:
​ 	​type​ LoyaltyCardId = ...
​ 	​type​ LoyaltyCardStatus =
​ 	  | None
​ 	  | LoyaltyCard ​of​ LoyaltyCardId
​ 	
​ 	​type​ CustomerInfo = {
​ 	  ...
​ 	  VipStatus : VipStatus
​ 	  LoyaltyCardStatus : LoyaltyCardStatus
​ 	  ...
​ 	  ​}

워크플로에 새 입력 추가

그런 다음 새로운 VipStatus 필드를 사용하고 있다고 가정해 보겠습니다.
언제나처럼 도메인 모델을 업데이트한 다음 그것이 우리를 어디로 이끄는지 확인할 것입니다.
먼저 상태 타입을 정의한 다음 이를 CustomerInfo의 필드로 추가합니다.

​ 	​type​ VipStatus = ...
​ 	
​ 	​type​ CustomerInfo = {
​ 	  ...
​ 	  VipStatus : VipStatus
​ 	  }
하지만 이렇게 하자마자 CustomerInfo를 구성하는 코드에서 컴파일러 오류가 발생합니다.
No assignment given for field 'VipStatus' of type 'CustomerInfo'
이것은 F# 레코드 타입의 좋은 점 중 하나를 보여줍니다.
객체 생성 시 모든 필드를 제공해야 합니다. 새 필드가 추가되면 제공할 때까지 컴파일러 오류가 발생합니다.
 
그렇다면 VipStatus는 어디에서 얻을 수 있습니까?
답 : 워크플로에 대한 입력인 UnvalidatedCustomerInfo.
 
그리고 그것은 어디에서 오나요?
답 : 사용자가 작성하는 주문 양식 DTO.
 
따라서 UnvalidatedCustomerInfo 및 DTO에도 해당 필드를 추가해야 합니다.
그러나 이 두 가지 모두에 대해 null을 사용하여 누락된 값을 나타내는 단순한 문자열이 될 수 있습니다.
 
​ 	​module​ Domain =
​ 	  ​type​ UnvalidatedCustomerInfo = {
​ 	    ...
​ 	    VipStatus : ​string​
​ 	    }
​ 	
​ 	​module​ Dto =
​ 	  ​type​ CustomerInfo = {
​ 	    ...
​ 	    VipStatus : ​string​
​ 	    }
이제 마지막으로 UnvalidatedCustomerInfo의 상태 필드와 다른 모든 필드를 사용하여 ValidatedCustomerInfo를 생성할 수 있습니다.
​ 	​let​ validateCustomerInfo unvalidatedCustomerInfo =
​ 	  result {
​ 	    ...
​ 	
​ 	    ​// new field​
​ 	    ​let​! vipStatus =
​ 	      VipStatus.create unvalidatedCustomerInfo.VipStatus
​ 	
​ 	    ​let​ customerInfo : CustomerInfo = {
​ 	      ...
​ 	      VipStatus = vipStatus
​ 	      }
​ 	    ​return​ customerInfo
​ 	  }

Adding the Free Shipping Rule to the Workflow

요구 사항 중 하나는 VIP에게 무료 배송을 제공하는 것이므로 이 논리를 워크플로 어딘가에 추가해야 합니다.
다시 말하지만, 안정적인 코드를 수정하는 대신 그림과 같이 파이프라인에 다른 세그먼트를 추가하기만 하면 됩니다.
이전과 마찬가지로 새 세그먼트를 나타내는 타입을 정의하는 것으로 시작합니다.
        type AddShippingInfoToOrder = PricedOrder -> PricedOrderWithShippingInfo

        //>addShippingInfoToOrderImpl 
        let addShippingInfoToOrder calculateShippingCost : AddShippingInfoToOrder  =
            fun pricedOrder ->
                // create the shipping info
                let shippingInfo = {
                    ShippingMethod = dotDotDot()
                    ShippingCost = calculateShippingCost pricedOrder 
                    }

                // add it to the order
                {
                OrderId = pricedOrder.OrderId
                //...
                ShippingInfo = shippingInfo
                }
        //<
        
        
        
​ 	​type​ FreeVipShipping =
​ 	  PricedOrderWithShippingMethod -> PricedOrderWithShippingMethod
그런 다음 해당 타입을 구현하는 워크플로 세그먼트를 만들고 워크플로에 삽입합니다. 코드를 보여줄 필요가 없습니다. 이제 이것이 어떻게 작동하는지 아실 것입니다. 

Change 3: Adding Support for Promotion Codes (프로모션 코드 지원 추가  - 가격 책정 로직의 변경, 이벤트 출력의 변화)

영업 팀은 몇 가지 판촉을 하려고 하고 주문할 때 제공될 수 있는 판촉 코드를 제공하여 할인된 가격을 받기를 원합니다.
 
영업 팀과 논의한 후 다음과 같은 새로운 요구 사항이 있습니다.
  • 주문할 때 고객은 선택적 프로모션 코드를 제공할 수 있습니다.
  • 코드가 있는 경우 특정 제품에는 다른(더 낮은) 가격이 제공됩니다.
  • 주문에 판촉 할인이 적용되었음을 표시해야 합니다.

도메인 모델에 프로모션 코드 추가

새 프로모션 코드 필드부터 시작하겠습니다. 언제나처럼 도메인 모델을 업데이트하고 그것이 우리를 어디로 이끄는지 확인할 것입니다.
프로모션 코드의 타입을 정의한 다음 주문의 선택적 필드로 추가하는 것으로 시작하겠습니다.
​ 	​type​ PromotionCode = PromotionCode ​of​ ​string​
​ 	
​ 	​type​ ValidatedOrder = {
​ 	  ...
​ 	  PromotionCode : PromotionCode option
​ 	  }
PromotionCode에 대한 특별한 유효성 검사는 없지만 도메인의 다른 문자열과 섞이지 않도록 그냥 문자열이 아닌 타입을 사용하는 것이 좋습니다.
이전의 VipStatus 필드와 마찬가지로 새 필드를 추가하면 일련의 컴파일러 오류가 트리거됩니다.
이 경우 UnvalidatedOrder 및 DTO에도 해당 필드를 추가해야 합니다.
ValidatedOrder의 필드가 명시적으로 선택 사항으로 표시되더라도 null이 누락된 값을 나타낼 것이라는 가정 하에 DTO에서 선택 사항이 아닌 문자열을 사용할 수 있습니다.
​ 	​type​ OrderDto = {
​ 	  ...
​ 	  PromotionCode : ​string​
​ 	  }
​ 	
​ 	​type​ UnvalidatedOrder = {
​ 	  ...
​ 	  PromotionCode : ​string​
​ 	  }​

Changing the Pricing Logic( 가격 책정 로직 변경)

프로모션 코드가 있는 경우 한 가지 종류의 가격 계산을 수행해야 하고, 없는 경우 다른 한 가지를 수행해야 합니다.
도메인에서 이것을 어떻게 모델링할 수 있습니까? 우리는 이미 함수 타입을 사용하여 가격 계산을 모델링했습니다.
​type​ GetProductPrice = ProductCode -> Price
그러나 이제 프로모션 코드를 기반으로 다른 GetProductPrice 함수를 제공해야 합니다. 논리는 다음과 같습니다.
  • 프로모션 코드가 있는 경우 해당 프로모션 코드와 관련된 가격을 반환하는 GetProductPrice 함수를 제공합니다.
  • 프로모션 코드가 없으면 원래 GetProductPrice 함수를 제공합니다.
우리에게 필요한 것은 선택적 프로모션 코드가 주어지면 다음과 같이 적절한 GetProductPrice 함수를 반환하는 "팩토리" 함수입니다.
​ 	​type​ GetPricingFunction = PromotionCode option -> GetProductPrice
옵션을 전달하는 것은 다소 불분명해 보입니다. 좀더 명시적인 타입을 만들어 봅시다.
​ 	​type​ PricingMethod =
​ 	  | Standard
​ 	  | Promotion ​of​ PromotionCode
논리적으로 이것은 옵션과 동일하지만 도메인 모델에서 사용할 때 조금 더 명확합니다.
ValidatedOrder 타입은 이제 다음과 같습니다.
​ 	​type​ ValidatedOrder = {
​ 	  ... ​//as before​
​ 	  PricingMethod : PricingMethod
​ 	  }
GetPricingFunction은 다음과 같습니다.
​type​ GetPricingFunction = PricingMethod -> GetProductPrice
한 가지 더 변경해야 합니다. 원래 디자인에서는 워크플로의 가격 책정 단계에 GetProductPrice 함수를 삽입했습니다. 이제 가격 책정 단계에 GetPricingFunction "팩토리" 함수를 주입해야 합니다.
​ 	​type​ PriceOrder =
​ 	  GetPricingFunction  ​// new dependency​
​ 	    -> ValidatedOrder ​// input​
​ 	    -> PricedOrder    ​// output

 

도메인 모델에 이러한 변경 사항을 적용하면 구현에서 다시 많은 컴파일 오류가 발생합니다.
이러한 컴파일러 오류는 구현을 수정하기 위해 수행해야 하는 작업을 안내합니다. 지루하지만 간단한 과정입니다.
하지만 일단 완료하고 구현이 다시 컴파일되면 모든 것이 오류 없이 작동한다는 확신을 가질 수 있습니다.

Implementing the GetPricingFunction (GetPricingFunction 구현)

GetPricingFunction이 어떻게 구현되는지 간단히 살펴보겠습니다.

각 프로모션 코드가 (ProductCode , Price) 쌍의 사전과 연결되어 있다고 가정합니다. 이 경우 구현은 다음과 같을 수 있습니다.

​ 	​type​ GetStandardPriceTable =
​ 	  ​// no input -> return standard prices​
​ 	  ​unit​ -> IDictionary<ProductCode,Price>
​ 	
​ 	​type​ GetPromotionPriceTable =
​ 	  ​// promo input -> return prices for promo​
​ 	  PromotionCode -> IDictionary<ProductCode,Price>
​ 	
let getPricingFunction
 	(standardPrices:GetStandardPriceTable)
 	(promoPrices:GetPromotionPriceTable)
 	: GetPricingFunction =
​ 	
​ 	
​ 	  ​// the original pricing function​
​ 	  ​let​ getStandardPrice : GetProductPrice =
​ 	    ​// cache the standard prices​
​ 	    ​let​ standardPrices = standardPrices()
​ 	    ​// return the lookup function​
​ 	    ​fun​ productCode -> standardPrices.[productCode]
​ 	
​ 	  ​// the promotional pricing function​
​ 	  ​let​ getPromotionPrice promotionCode : GetProductPrice =
​ 	    ​// cache the promotional prices​
​ 	    ​let​ promotionPrices = promoPrices promotionCode
​ 	    ​// return the lookup function​
​ 	    ​fun​ productCode ->
​ 	      ​match​ promotionPrices.TryGetValue productCode ​with​
​ 	      ​// found in promotional prices​
​ 	      | true,price -> price
​ 	      ​// not found in promotional prices​
​ 	      ​// so use standard price​
​ 	      | false, _ -> getStandardPrice productCode
​ 	
​ 	  ​// return a function that conforms to GetPricingFunction​
​ 	  ​fun​ pricingMethod ->
​ 	    ​match​ pricingMethod ​with​
​ 	    | Standard ->
​ 	      getStandardPrice
​ 	    | Promotion promotionCode ->
​ 	      getPromotionPrice promotionCode​
  • 테이블을 리턴하는 함수를 의존성으로 주입받음.
  • 해당 테이블들로 서치 함수를 만듬 (getStandardPrice, getPromotionPrice)
    • getPromotionPrice는 서치 fail 시 getStandardPrice로 할인 없는 가격 리턴
  • pricingMethod 필드와 매치하여 함수 리턴
사용하는 함수형 기술들은 다음과 같습니다.
  • 코드가 올바른지 확인하고(GetProductPrice) 도메인 논리를 명확하게 만들기 위한 타입(PricingMethod 초이스 타입),
  • 매개변수로 함수(GetPromotionPriceTable 유형의 promoPrices)
  • 출력으로 함수(GetPricingFunction 유형의 반환 값).

Documenting the Discount in an Order Line (주문 라인 할인 문서화)

요구 사항 중 하나는 "주문에 판촉 할인이 적용되었음을 표시해야 합니다."였습니다. 어떻게 해야 할까요?
 
이에 대한 답을 얻으려면 다운스트림 시스템이 프로모션에 대해 알아야 하는지 여부를 알아야 합니다.
그렇지 않은 경우 가장 간단한 옵션은 주문 라인 목록에 "주석 라인"을 추가하는 것입니다.
코멘트에 특별한 세부 사항이 필요하지 않으며 할인을 설명하는 텍스트만 있으면 됩니다. (마지막 한 줄)
이는 "주문 라인"의 정의를 변경해야 함을 의미합니다.
 
지금까지 우리는 주문 라인이 항상 특정 제품을 참조한다고 가정했습니다.
그러나 이제는 제품을 참조하지 않는 새로운 종류의 주문 라인이 있다고 말할 필요가 있습니다.
그것은 우리의 도메인 모델에 대한 변경입니다. 이를 반영하도록 PricedOrderLine 정의를 초이스 타입으로 변경해 보겠습니다.
 
​ 	​type​ CommentLine = CommentLine ​of​ ​string​
​ 	
​ 	​type​ PricedOrderLine =
​ 	  | Product ​of​ PricedOrderProductLine
​ 	  | Comment ​of​ CommentLine
문자 수가 너무 많지 않은지 확인하는 경우를 제외하고는 CommentLine 타입에 대한 특별한 유효성 검사가 필요하지 않습니다.
주석보다 더 자세한 정보를 추적해야 하는 경우 할인 금액 등과 같은 데이터를 포함하는 DiscountApplied 사례를 대신 정의할 수 있습니다.
Comment 사용의 장점은 배송 컨텍스트와 청구 컨텍스트가 프로모션에 대해 전혀 알 필요가 없으므로 프로모션 로직이 변경되더라도 영향을 받지 않는다는 것입니다.
이제 PricedOrderLine을 초이스 타입으로 변경했으므로 가격, 수량 등과 같이 제품 지향적인 행의 세부 정보를 포함하는 새로운 PricedOrderProductLine 타입도 필요합니다.
 
마지막으로 ValidatedOrderLine 및 PricedOrderLine이 이제 디자인에서 분기되었음을 알 수 있습니다.
이것은 도메인 모델링 중에 타입을 분리하는 것이 좋은 아이디어라는 것을 보여줍니다. 이러한 종류의 변경이 필요할 때 알 수 없습니다.
동일한 타입이 둘 다에 사용되었다면 모델을 깨끗하게 유지할 수 없었을 것입니다.
 
CommentLine을 추가하려면 priceOrder 함수를 변경해야 합니다.
 
  • 먼저 GetPricingFunction "factory"에서 가격 책정 함수를 가져옵니다.
  • 그런 다음 각 라인에 대해 해당 가격 책정 함수를 사용하여 가격을 설정합니다.
  • 마지막으로 프로모션 코드가 사용된 경우 라인 목록에 특수 주석 라인을 추가합니다. (마지막에 한줄)
​ 	​let​ toPricedOrderLine orderLine = ...
​ 	
​ 	​let​ priceOrder : PriceOrder =
​ 	  ​fun​ getPricingFunction validatedOrder ->
​ 	    ​// get the pricing function from the getPricingFunction "factory"​
​ 	    ​let​ getProductPrice = getPricingFunction validatedOrder.PricingMethod
​ 	
​ 	    ​// set the price for each line​
​ 	    ​let​ productOrderLines =
​ 	      validatedOrder.OrderLines
​ 	      |> List.map (toPricedOrderLine getProductPrice)
​ 	
​ 	    ​// add the special comment line if needed​
​ 	    ​let​ orderLines =
​ 	        ​match​ validatedOrder.PricingMethod ​with​
​ 	        | Standard ->
​ 	          ​// unchanged​
​ 	          productOrderLines
​ 	        | Promotion promotion ->
​ 	          ​let​ promoCode = promotion|> PromotionCode.value
​ 	          ​let​ commentLine =
​ 	            sprintf ​"Applied promotion %s"​ promoCode
​ 	            |> CommentLine.create
​ 	            |> Comment ​// lift to PricedOrderLine​
​ 	          List.append productOrderLines [commentLine]
​ 	
​ 	    ​// return the new order​
​ 	    {
​ 	        ...
​ 	        OrderLines = orderLines
​ 	    }​

More Complicated Pricing Schemes (더 복잡한 가격 체계)

많은 경우 가격 책정 체계는 여러 판촉 행사, 상품권, 로열티 체계 등으로 인해 훨씬 ​​더 복잡해질 수 있습니다.
이런 일이 발생하면 가격 책정이 별도의 경계 컨텍스트가 되어야 한다는 신호일 수 있습니다.
경계 컨텍스트를 올바르게 설정하는 방법에 대한 논의를 기억하십니까? 다음은 단서입니다.
  • 고유한 어휘 ("BOGOF"와 같은 전문용어 포함)
  • 가격을 관리하는 전담팀
  • 해당 컨텍스트에만 해당하는 데이터 (예: 이전 구매 및 바우처 사용 여부)
  • 자율적인 발전
가격 책정이 비즈니스의 중요한 부분이라면 가격 책정이 독립적으로 발전하고 주문 접수, 배송 및 청구 영역에서 분리된 상태를 유지하는 것만큼이나 중요합니다.
다음은 주문 접수와 밀접하게 관련되어 있지만
이제는 논리적으로 분리된 고유한 경계 컨텍스트로서의 가격 책정을 보여주는 다이어그램입니다.

Evolving the Contract Between Bounded Contexts (바운디드 컨텍스트 사이의 컨트랙트 진화)

주문을 올바르게 인쇄하기 위해 배송 시스템이 알아야 하는 새로운 종류의 라인인 CommentLine을 도입했습니다.
즉, 다운스트림으로 전송되는 OrderPlaced 이벤트도 변경해야 합니다.
이제 우리는 Order-taking 컨텍스트와 Shipping 컨텍스트 간의 계약을 깨뜨렸습니다.
지속 가능한가요? 즉, 수주 영역에 새로운 개념을 추가할 때마다 이벤트와 DTO를 변경하고 계약을 파기해야 합니까?
분명히 아닙니다. 그러나 현재로서는 우리가 원하는 바가 아닌 바운디드 컨텍스트 간의 결합을 도입했습니다.
 
이전에 ​Contracts between Bounded Contexts​에서 논의했듯이,
이 문제에 대한 좋은 해결책은 "소비자 주도" 계약을 사용하는 것입니다.
이 접근 방식에서 (다운스트림) 소비자는 (업스트림) 생산자에게 필요한 것을 결정하고 생산자는 그 데이터만 제공해야 합니다.
이 상황에서 배송 컨텍스트가 실제로 필요한 것이 무엇인지 생각해 보겠습니다.
가격도 필요없고 배송비도 필요없고 할인정보도 필요없습니다.
필요한 것은 제품 목록, 각 제품의 수량, 배송 주소뿐입니다. 이를 나타내는 타입을 설계해 보겠습니다.
 
​ 	​type​ ShippableOrderLine = {
​ 	  ProductCode : ProductCode
​ 	  Quantity : ​float​
​ 	  }
​ 	
​ 	​type​ ShippableOrderPlaced = {
​ 	  OrderId : OrderId
​ 	  ShippingAddress : Address
​ 	  ShipmentLines : ShippableOrderLine ​list​
​ 	  }


이것은 원래 OrderPlaced 이벤트 타입보다 훨씬 간단합니다.
그리고 데이터가 적기 때문에 수주 도메인이 변경될 때 변경될 가능성이 적습니다.
이 새로운 이벤트 타입을 사용하여 주문 접수 워크플로의 PlaceOrderEvent 출력을 다시 디자인해야 합니다.
이제 다음과 같은 이벤트들이 있습니다.
 
  • 로깅과 고객 서비스 컨텍스트를 위한 AcknowledgementSent
  • 배송 컨텍스트로 보낼 ShippableOrderPlaced
  • 청구 컨텍스트로 보낼 BillableOrderPlaced
​ 	​type​ PlaceOrderEvent =
​ 	  | ShippableOrderPlaced ​of​ ShippableOrderPlaced
​ 	  | BillableOrderPlaced ​of​ BillableOrderPlaced
​ 	  | AcknowledgmentSent  ​of​ OrderAcknowledgmentSent

Printing the Order

주문을 인쇄하는 것은 어떻습니까? 주문 상품이 포장되어 배송 준비가 되면 원래 주문 사본이 인쇄되어 패키지에 들어갑니다.
사용 가능한 정보를 의도적으로 줄인 경우 배송 부서에서 주문을 어떻게 인쇄할 수 있습니까?
 
여기서 핵심은 배송 부서에서 인쇄할 수 있는 것이 필요하지만 실제로 내용에는 관심이 없다는 점을 인식하는 것입니다.
즉, 주문 컨텍스트는 배송 부서에 PDF 또는 HTML 문서를 제공한 다음 인쇄하도록 할 수 있습니다.
 
이 문서는 위의 ShippableOrderPlaced 유형에서 바이너리 blob으로 제공되거나 PDF를 공유 저장소에 덤프하고 배송 컨텍스트가 OrderId를 통해 액세스하도록 할 수 있습니다.

Change 4: Adding a Business Hours Constraint (영업 시간 제한 - 인터페이스로서의 함수 타입)

지금까지 새로운 데이터와 기능을 추가하는 방법을 살펴보았습니다.
이제 워크플로가 사용되는 방식에 대한 새로운 제약 조건을 추가하는 방법을 살펴보겠습니다.
새로운 비즈니스 규칙은 다음과 같습니다.
주문은 영업시간에만 가능합니다.
이유가 무엇이든 회사는 시스템을 업무 시간에만 사용할 수 있어야 한다고 결정했습니다.
(아마도 새벽 4시에 사이트에 액세스하는 사람들이 실제 고객이 아닐 수도 있다는 가정 하에).
그렇다면 이것을 어떻게 구현할 수 있을까요? 이전에 본 트릭을 사용할 수 있습니다.
바로 "어댑터" 함수를 만드는 것입니다.
우리는 업무 시간 외에 호출되면 오류를 발생시키는 "래퍼" 또는 "프록시" 함수를 리턴하는 "업무 시간 전용" 함수를 만들 것입니다.

업무 시간에만 사용할 수 있도록 하기

이 변환된 기능은 원래 것과 정확히 동일한 입력 및 출력을 가지므로 원래 기능이 사용된 모든 곳에서 사용할 수 있습니다.
다음은 트랜스포머 함수의 코드입니다.
​ 	​/// Determine the business hours​
​ 	​let​ isBusinessHour hour =
​ 	  hour >= 9 && hour <= 17
​ 	
​ 	​/// tranformer​
​ 	​let​ businessHoursOnly getHour onError onSuccess =
​ 	  ​let​ hour = getHour()
​ 	  ​if​ isBusinessHour hour ​then​
​ 	    onSuccess()
​ 	  ​else​
​ 	    onError()
이것이 완전히 일반적인 코드임을 알 수 있습니다.
  • onError 매개변수는 업무 시간 외의 경우를 처리하는 데 사용됩니다.
  • onSuccess 매개변수는 업무 시간 내에 있는 경우를 처리하는 데 사용됩니다.
  • 시간은 하드 코딩되지 않고 getHour 함수 매개변수에 의해 결정됩니다.
이를 통해 쉬운 단위 테스트를 위해 더미 함수를 주입할 수 있습니다.
 
이 경우 원래 워크플로는 UnvalidatedOrder를 사용하고 오류 타입이 PlaceOrderError인 결과를 반환합니다.
따라서 우리가 전달한 onError는 동일한 유형의 Result를 반환해야 하므로
PlaceOrderError 타입에 OutsideBusinessHours 케이스를 추가해 보겠습니다.
​ 	​type​ PlaceOrderError =
​ 	  | Validation ​of​ ValidationError
​ 	  ...
​ 	  ​|​ OutsideBusinessHours  ​//new!​
이제 원래 주문 배치 워크플로를 변환하는 데 필요한 모든 것이 있습니다.
​ 	​let​ placeOrder unvalidatedOrder =
​ 	    ...
​ 	
​ 	​let​ placeOrderInBusinessHours unvalidatedOrder =
​ 	  ​let​ onError() =
​ 	    Error OutsideBusinessHours
​ 	  ​let​ onSuccess() =
​ 	    placeOrder unvalidatedOrder
​ 	  ​let​ getHour() = DateTime.Now.Hour
​ 	  businessHoursOnly getHour onError onSuccess
마지막으로 애플리케이션의 최상위 수준(컴포지션 루트)에서
원래 placeOrder 함수를 동일한 입력 및 출력을 가지므로 완전히 호환되는 새로운 placeOrderInBusinessHours 함수로 교체합니다.
(함수 시그니처 - 인터페이스를 유지하면서 기능을 추가하였습니다!)

Dealing with Additional Requirements Changes

분명히, 우리는 요구될 수 있는 종류의 변경으로 표면을 긁고 있을 뿐입니다. 다음은 고려해야 할 몇 가지 사항과 해결 방법입니다.
  • VIP는 미국 내 배송에 대해서만 무료 우표를 받아야 합니다. 이를 지원하려면 워크플로의 freeVipShipping 세그먼트에서 코드를 변경하기만 하면 됩니다. 지금쯤이면 이와 같은 작은 세그먼트를 많이 사용하는 것이 복잡성을 제어하는 ​​데 실제로 도움이 된다는 것이 분명해야 합니다.
  • 고객은 주문을 여러 배송으로 분할할 수 있어야 합니다. 이 경우 이를 수행하기 위한 몇 가지 논리가 필요합니다(워크플로의 새 세그먼트). 도메인 모델링 관점에서 유일한 변경 사항은 워크플로의 출력에 단일 항목이 아니라 배송 컨텍스트로 보낼 배송 목록이 포함된다는 것입니다.
  • 고객은 주문 상태(아직 배송되었는지, 전액 결제되었는지 등)를 볼 수 있어야 합니다. 이는 주문 상태에 대한 정보가 여러 컨텍스트로 분할되기 때문에 까다로운 문제입니다. 배송 컨텍스트는 배송 상태를 알고, 청구 컨텍스트는 청구 상태를 아는 식입니다. 가장 좋은 접근 방식은 아마도 이와 같은 고객 질문을 처리하는 새로운 컨텍스트("고객 서비스"라고 함)를 만드는 것입니다. 다른 컨텍스트의 이벤트를 구독하고 그에 따라 상태를 업데이트할 수 있습니다. 상태에 대한 모든 쿼리는 이 컨텍스트로 직접 이동합니다.
고객서비스 컨텍스트 (중재자 - CQRS의 쿼리 역할을 하는 새로운 컨텍스트)의 등장!

마무리

우리는 이 책에서 바운디드 컨텍스트와 같은 고급 추상화부터 직렬화 포맷의 세부 사항에 이르기까지 많은 내용을 다뤘습니다.
 
 
지금까지 이야기한 가장 중요한 몇 가지 사례를 살펴보겠습니다.
  • 우리는 저수준 설계를 시작하기 전에 도메인에 대한 깊고 공유된 이해를 발전시키는 것을 목표로 해야 합니다. 우리는 이 과정에서 큰 도움이 될 몇 가지 발견 기술(이벤트 스토밍)과 커뮤니케이션 기술(유비쿼터스 언어 사용)을 선택했습니다.
  • 솔루션 공간이 독립적으로 발전할 수 있는 분리된 자율적 경계 컨텍스트로 분할되고 각 워크플로가 명시적 입력 및 출력이 있는 독립 실행형 파이프라인으로 표시되어야 합니다.
  • 코드를 작성하기 전에 도메인의 명사와 동사를 모두 캡처하기 위해 타입 기반 표기법을 사용하여 요구 사항을 캡처해야 합니다. 우리가 보았듯이 명사는 거의 항상 대수적 타입 체계로 표현되고 동사는 함수로 표현될 수 있습니다.
  • 가능한 한 타입 시스템에서 중요한 제약 조건과 비즈니스 규칙을 포착하려고 노력해야 합니다. 우리의 모토는 "make illegal states unrepresentable."입니다.
  • 우리는 모든 가능한 입력이 명시적으로 문서화된 출력(예외 없음)을 갖고 모든 동작이 완전히 예측 가능하도록(숨겨진 종속성이 없음) "순수" 및 "완전"하게 함수을 설계해야 합니다.
주문 배치 워크플로와 함께 이 프로세스를 거친 후 구현을 안내하고 제한하는 데 사용하는 세부 타입 집합이 완성되었습니다.
그런 다음 구현 과정에서 다음과 같은 중요한 함수형 프로그래밍 기술을 반복적으로 사용했습니다.
 
  • 더 작은 합수의 합성만을 사용하여 완전한 워크플로 구축
  • 종속성이 있거나, 실행을 연기하려는 결정이 있을 때마다 함수를 매개변수화합니다.
  • 부분 적용을 사용하여 종속성을 함수로 베이킹하여 함수를 보다 쉽게 ​​합성하고 불필요한 구현 세부 정보를 숨길 수 있습니다.
  • 다른 함수를 다양한 모양으로 트랜스폼 할 수 있는 특수 함수 생성 -  특히 우리는 오류 반환 함수를 쉽게 합성할 수 있는 2트랙 함수로 변환하는 데 사용한 "어댑터 블록"인 bind에 대해 배웠습니다.
  • 이질적인 타입을 공통 타입으로 "리프팅"하여 타입 불일치 문제를 해결합니다.
 
이 책에서 나는 함수형 프로그래밍과 도메인 모델링이 아주 잘 맞는다는 것을 당신에게 확신시키는 것을 목표로 삼았습니다.
이제 당신이 나가서 배운 것을 자신의 응용 프로그램에 사용할 수 있는 자신감을 갖기를 바랍니다.



참고 (고통을 참고 더 읽어보기) :  

https://functionalprogramming.medium.com/monads-kleisli-composition-5b42fe2f92f2

 

Monads — Kleisli Composition

if we look at Wikipedia the definition of Kleisli it seems its something complex. Don’t worry its actually simple.

functionalprogramming.medium.com

 

반응형