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 : 초이스 타입을 이용해 관심사가 유사한 필드를 하나의 차원으로 통합하라!
- 워크플로에 새 단계 추가
- 워크플로에 대한 입력 변경
- 키 도메인 타입(Order) 정의 변경에 의한 파급 효과 확인
- 비즈니스 규칙을 준수하도록 전체 워크플로 변환
Change 1: Adding Shipping Charges (배송 요금 추가하기)
첫 번째 요구 사항 변경을 위해 배송 및 배송 비용을 계산하는 방법을 살펴보겠습니다.
회사에서 특별한 계산을 사용하여 고객에게 배송료를 청구하려고 한다고 가정해 보겠습니다.
이 새로운 요구 사항을 어떻게 통합할 수 있습니까?
/// 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 (비즈니스 로직 단순화를 위한 액티브 패턴 사용)
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
Creating a New Stage in the Workflow (워크플로에 새 단계 추가)
type AddShippingInfoToOrder = PricedOrder -> PricedOrderWithShippingInfo
type ShippingMethod =
| PostalService
| Fedex24
| Fedex48
| Ups48
type ShippingInfo = {
ShippingMethod : ShippingMethod
ShippingCost : Price
}
type PricedOrderWithShippingMethod = {
ShippingInfo : ShippingInfo
PricedOrder : PricedOrder
}
- PricedOrderWithShippingInfo를 입력으로 예상하도록 AcknowledgeOrder 단계를 수정하면 단계의 순서를 잘못 가져올 수 없습니다.
- PricedOrder의 필드로 ShippingInfo를 추가하면 배송 계산이 완료되기 전에 무엇으로 초기화해야 합니까? 단순히 기본값으로 초기화하면 버그가 발생할 수 있습니다.
마지막 문제: 배송 비용을 주문에 어떻게 저장해야 합니까? 다음과 같이 헤더의 필드여야 합니까?
type PricedOrder = {
...
ShippingInfo : ShippingInfo
OrderTotal : Price
}
type PricedOrderLine =
| Product of PricedOrderProductLine
| ShippingInfo of ShippingInfo
헤더에 배송 정보를 저장해 보겠습니다.
- 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
}
// 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
type CustomerInfo = {
...
IsVip : bool
...
}
type CustomerStatus =
| Normal of CustomerInfo
| Vip of CustomerInfo
type Order = {
...
CustomerStatus : CustomerStatus
...
}
type VipStatus =
| Normal
| Vip
type CustomerInfo = {
...
VipStatus : VipStatus
...
}
type LoyaltyCardId = ...
type LoyaltyCardStatus =
| None
| LoyaltyCard of LoyaltyCardId
type CustomerInfo = {
...
VipStatus : VipStatus
LoyaltyCardStatus : LoyaltyCardStatus
...
}
워크플로에 새 입력 추가
type VipStatus = ...
type CustomerInfo = {
...
VipStatus : VipStatus
}
No assignment given for field 'VipStatus' of type 'CustomerInfo' |
module Domain =
type UnvalidatedCustomerInfo = {
...
VipStatus : string
}
module Dto =
type CustomerInfo = {
...
VipStatus : string
}
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
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
}
type OrderDto = {
...
PromotionCode : string
}
type UnvalidatedOrder = {
...
PromotionCode : string
}
Changing the Pricing Logic( 가격 책정 로직 변경)
type GetProductPrice = ProductCode -> Price
- 프로모션 코드가 있는 경우 해당 프로모션 코드와 관련된 가격을 반환하는 GetProductPrice 함수를 제공합니다.
- 프로모션 코드가 없으면 원래 GetProductPrice 함수를 제공합니다.
type GetPricingFunction = PromotionCode option -> GetProductPrice
type PricingMethod =
| Standard
| Promotion of PromotionCode
type ValidatedOrder = {
... //as before
PricingMethod : PricingMethod
}
type GetPricingFunction = PricingMethod -> GetProductPrice
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 (주문 라인 할인 문서화)
type CommentLine = CommentLine of string
type PricedOrderLine =
| Product of PricedOrderProductLine
| Comment of CommentLine
- 먼저 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 (바운디드 컨텍스트 사이의 컨트랙트 진화)
type ShippableOrderLine = {
ProductCode : ProductCode
Quantity : float
}
type ShippableOrderPlaced = {
OrderId : OrderId
ShippingAddress : Address
ShipmentLines : ShippableOrderLine list
}
- 로깅과 고객 서비스 컨텍스트를 위한 AcknowledgementSent
- 배송 컨텍스트로 보낼 ShippableOrderPlaced
- 청구 컨텍스트로 보낼 BillableOrderPlaced
type PlaceOrderEvent =
| ShippableOrderPlaced of ShippableOrderPlaced
| BillableOrderPlaced of BillableOrderPlaced
| AcknowledgmentSent of OrderAcknowledgmentSent
Printing the Order
Change 4: Adding a Business Hours Constraint (영업 시간 제한 - 인터페이스로서의 함수 타입)
주문은 영업시간에만 가능합니다.
/// 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 함수 매개변수에 의해 결정됩니다.
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
Dealing with Additional Requirements Changes
- VIP는 미국 내 배송에 대해서만 무료 우표를 받아야 합니다. 이를 지원하려면 워크플로의 freeVipShipping 세그먼트에서 코드를 변경하기만 하면 됩니다. 지금쯤이면 이와 같은 작은 세그먼트를 많이 사용하는 것이 복잡성을 제어하는 데 실제로 도움이 된다는 것이 분명해야 합니다.
- 고객은 주문을 여러 배송으로 분할할 수 있어야 합니다. 이 경우 이를 수행하기 위한 몇 가지 논리가 필요합니다(워크플로의 새 세그먼트). 도메인 모델링 관점에서 유일한 변경 사항은 워크플로의 출력에 단일 항목이 아니라 배송 컨텍스트로 보낼 배송 목록이 포함된다는 것입니다.
- 고객은 주문 상태(아직 배송되었는지, 전액 결제되었는지 등)를 볼 수 있어야 합니다. 이는 주문 상태에 대한 정보가 여러 컨텍스트로 분할되기 때문에 까다로운 문제입니다. 배송 컨텍스트는 배송 상태를 알고, 청구 컨텍스트는 청구 상태를 아는 식입니다. 가장 좋은 접근 방식은 아마도 이와 같은 고객 질문을 처리하는 새로운 컨텍스트("고객 서비스"라고 함)를 만드는 것입니다. 다른 컨텍스트의 이벤트를 구독하고 그에 따라 상태를 업데이트할 수 있습니다. 상태에 대한 모든 쿼리는 이 컨텍스트로 직접 이동합니다.

마무리
- 우리는 저수준 설계를 시작하기 전에 도메인에 대한 깊고 공유된 이해를 발전시키는 것을 목표로 해야 합니다. 우리는 이 과정에서 큰 도움이 될 몇 가지 발견 기술(이벤트 스토밍)과 커뮤니케이션 기술(유비쿼터스 언어 사용)을 선택했습니다.
- 솔루션 공간이 독립적으로 발전할 수 있는 분리된 자율적 경계 컨텍스트로 분할되고 각 워크플로가 명시적 입력 및 출력이 있는 독립 실행형 파이프라인으로 표시되어야 합니다.
- 코드를 작성하기 전에 도메인의 명사와 동사를 모두 캡처하기 위해 타입 기반 표기법을 사용하여 요구 사항을 캡처해야 합니다. 우리가 보았듯이 명사는 거의 항상 대수적 타입 체계로 표현되고 동사는 함수로 표현될 수 있습니다.
- 가능한 한 타입 시스템에서 중요한 제약 조건과 비즈니스 규칙을 포착하려고 노력해야 합니다. 우리의 모토는 "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
GitHub - MehdiZonjy/domain-modeling-made-functional-ts: An attempt to implement DomainModelingMadeFunctional book samples using
An attempt to implement DomainModelingMadeFunctional book samples using Typescript and fp-ts - GitHub - MehdiZonjy/domain-modeling-made-functional-ts: An attempt to implement DomainModelingMadeFunc...
github.com
https://itnext.io/parse-dont-validate-incoming-data-in-typescript-d6d5bfb092c8
Parse, don’t validate, incoming data in TypeScript.
How to work with libraries like io-ts, Runtypes and Zod to parse all incoming & outgoing data in your applications type safely.
itnext.io
https://dev.to/derp/bounded-types-in-fp-ts-7db
'BackEnd' 카테고리의 다른 글
[Java, Spring] 프록시, 프록시 패턴, 데코레이터 패턴 (0) | 2023.06.18 |
---|---|
[Java, Spring] 쓰레드 로컬과 전략 패턴, 탬플릿 메서드 패턴 (0) | 2023.06.04 |
Persistence (0) | 2022.03.21 |
Serialization (0) | 2022.03.21 |
DDD 구현 : 모나드와 Async (0) | 2022.03.21 |