https://pragprog.com/titles/swdddf/domain-modeling-made-functional/
이펙트 시그니처를 피해가며 파이프라인의 단계를 구현하고, 종속성을 주입하는 방법을 알아봅니다.
또한 세 가지 함수형 프로그래밍 패턴을 알아봅니다.
- "adapter function"를 사용하여 함수를 한 "모양"에서 다른 "모양"으로 변환.
- (ex : checkProductCodeExists의 출력을 bool에서 ProductCode로 변경)
- 다른 타입을 공통 타입으로 "lifting"
- Partial Application을 사용하여 종속성을 함수 안에 "bake in"
- 함수를 보다 쉽게 합성할 수 있고 호출자로부터 불필요한 구현 세부 정보를 숨길 수 있습니다.
Implementation: Composing a Pipeline
https://itchallenger.tistory.com/423
기술적인 관점에서 파이프라인에는 다음 단계가 있습니다.
- UnvalidatedOrder로 시작하여 ValidatedOrder로 변환하고 유효성 검사가 실패하면 오류를 반환합니다.
- 유효성 검사 단계의 출력(ValidatedOrder)을 가져와서 몇 가지 추가 정보를 추가하여 PricedOrder로 바꿉니다.
- 가격 책정 단계의 결과를 가져와서 주문 승인 메일을 송신합니다.
- 발생한 일을 나타내는 이벤트 집합을 만들고 반환합니다.
let placeOrder unvalidatedOrder =
unvalidatedOrder
|> validateOrder
|> priceOrder
|> acknowledgeOrder
|> createEvents
- 종속성 :
- 일부 함수에는 데이터 파이프라인의 일부가 아니지만 구현에 필요한 추가 매개변수가 있습니다. 이러한 매개변수를 "종속성"이라고 합니다.
- 이펙트 :
- 함수 시그니처에서 Result와 같은 래퍼 유형을 사용하여 오류 처리와 같은 "효과"를 명시적으로 표시했습니다. 그러나 이는 출력에 이펙트가 있는 함수가 래핑되지 않은 primitive 데이터를 입력으로 갖는 함수에 직접 연결할 수 없음을 의미합니다.
Simple Type 다루기 ((단일) 초이스 타입)
- string 또는 int와 같은 primitive에서 타입을 생성하는 create 함수
- 예를 들어 OrderId.create는 문자열에서 OrderId를 생성하거나 문자열이 잘못된 형식인 경우 오류를 발생시킵니다.
- Wrapper 타입 내부의 primitive 값을 추출하는 값 함수
//>SimpleTypes1
module Domain =
type OrderId = private OrderId of string
module OrderId =
/// Define a "Smart constructor" for OrderId
/// string -> OrderId
let create str =
if String.IsNullOrEmpty(str) then
// use exceptions rather than Result for now
failwith "OrderId must not be null or empty"
elif str.Length > 50 then
failwith "OrderId must not be more than 50 chars"
else
OrderId str
/// Extract the inner value from an OrderId
/// OrderId -> string
let value (OrderId str) = // unwrap in the parameter!
str // return the inner value
//<
- create 함수는 지금은 효과를 피하고 있기 때문에 Result type을 반환하는 대신 오류에 대해 예외(failwith)를 사용한다는 점을 제외하면 설계와 유사합니다.
- value 함수는 매개변수를 이용해 한 단계 패턴 매치를 수행 후 추출합니다.
함수 타입을 구현 지침으로 사용하기
모델링 장에서 워크플로의 각 단계를 나타내는 특수 함수 타입을 정의했습니다.
이제 구현할 시간입니다. 코드가 이를 준수하도록 하려면 어떻게 해야 할까요?
let validateOrder
checkProductCodeExists // dependency
checkAddressExists // dependency
unvalidatedOrder = // input
...
(주 : DotDotDot은 IDE 빨간줄을 피하기위한 PlaceHolder로 ...를 의미합니다. 지금은 디테일이 중요한게 아니니까요.)
type Param1 = DotDotDot
type Param2 = DotDotDot
type Result = DotDotDot
(*
"if we want to make it clear that we are implementing a specific function signature"
*)
//>UsingFunctionSignatures2
// define a function signature
type MyFunctionSignature = Param1 -> Param2 -> Result
// define a function that implements that signature
let myFunc: MyFunctionSignature =
fun param1 param2 ->
dotDotDot()
//<
type ValidateOrder =
CheckProductCodeExists // dependency
-> CheckAddressExists // dependency
-> UnvalidatedOrder // input
-> Result<ValidatedOrder,ValidationError list> // output
(*
"Applying this approach to the `validateOrder` function gives us:"
*)
//>UsingFunctionSignatures3
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
// ^dependency ^dependency ^input
dotDotDot()
//<
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
if checkProductCodeExists 42 then
// compiler error ^
// This expression was expected to have type ProductCode
// but here has type int
...
...
Order의 유효성 검사 구현
type CheckAddressExists =
UnvalidatedAddress -> AsyncResult<CheckedAddress,AddressValidationError>
type ValidateOrder =
CheckProductCodeExists // dependency
-> CheckAddressExists // AsyncResult dependency
-> UnvalidatedOrder // input
-> AsyncResult<ValidatedOrder,ValidationError list> // output
type CheckAddressExists =
UnvalidatedAddress -> CheckedAddress
type ValidateOrder =
CheckProductCodeExists // dependency
-> CheckAddressExists // dependency
-> UnvalidatedOrder // input
-> ValidatedOrder // output
// ValidateOrder에 인자 2개만 주입하면 CheckAddressExists가 됩니다!!
- UnvalidatedOrder의 OrderId 문자열에서 OrderId 도메인 타입을 생성합니다.
- UnvalidatedOrder의 UnvalidatedCustomerInfo 필드에서 CustomerInfo 도메인 타입을 생성합니다.
- UnvalidatedAddress 타입인 UnvalidatedOrder의 ShippingAddress 필드에서 Address 도메인 타입을 생성합니다.
- BillingAddress 및 기타 모든 속성에 대해 동일한 작업을 수행합니다.
- ValidatedOrder의 모든 구성 요소를 사용할 수 있게 되면 일반적인 방법으로 레코드를 만들 수 있습니다.
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
let orderId =
unvalidatedOrder.OrderId
|> OrderId.create
let customerInfo =
unvalidatedOrder.CustomerInfo
|> toCustomerInfo // helper function
let shippingAddress =
unvalidatedOrder.ShippingAddress
|> toAddress // helper function
// and so on, for each property of the unvalidatedOrder
// when all the fields are ready, use them to
// create and return a new "ValidatedOrder" record
{
OrderId = orderId
CustomerInfo = customerInfo
ShippingAddress = shippingAddress
BillingAddress = ...
Lines = ...
}
let toCustomerInfo (customer:UnvalidatedCustomerInfo) : CustomerInfo =
// create the various CustomerInfo properties
// and throw exceptions if invalid
let firstName = customer.FirstName |> String50.create
let lastName = customer.LastName |> String50.create
let emailAddress = customer.EmailAddress |> EmailAddress.create
// create a PersonalName
let name : PersonalName = {
FirstName = firstName
LastName = lastName
}
// create a CustomerInfo
let customerInfo : CustomerInfo = {
Name = name
EmailAddress = emailAddress
}
// ... and return it
customerInfo
유효한 CheckedAddress 만들기
let toAddress (checkAddressExists:CheckAddressExists) unvalidatedAddress =
// call the remote service
let checkedAddress = checkAddressExists unvalidatedAddress
// extract the inner value using pattern matching
let (CheckedAddress checkedAddress) = checkedAddress
let addressLine1 =
checkedAddress.AddressLine1 |> String50.create
let addressLine2 =
checkedAddress.AddressLine2 |> String50.createOption
let addressLine3 =
checkedAddress.AddressLine3 |> String50.createOption
let addressLine4 =
checkedAddress.AddressLine4 |> String50.createOption
let city =
checkedAddress.City |> String50.create
let zipCode =
checkedAddress.ZipCode |> ZipCode.create
// create the address
let address : Address = {
AddressLine1 = addressLine1
AddressLine2 = addressLine2
AddressLine3 = addressLine3
AddressLine4 = addressLine4
City = city
ZipCode = zipCode
}
// return the address
address
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
let orderId = ...
let customerInfo = ...
let shippingAddress =
unvalidatedOrder.ShippingAddress
|> toAddress checkAddressExists // new parameter
...
주문 라인 리스트 생성
let toValidatedOrderLine checkProductCodeExists
(unvalidatedOrderLine:UnvalidatedOrderLine) =
let orderLineId =
unvalidatedOrderLine.OrderLineId
|> OrderLineId.create
let productCode =
unvalidatedOrderLine.ProductCode
|> toProductCode checkProductCodeExists // helper function
let quantity =
unvalidatedOrderLine.Quantity
|> toOrderQuantity productCode // helper function
let validatedOrderLine = {
OrderLineId = orderLineId
ProductCode = productCode
Quantity = quantity
}
validatedOrderLine
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
let orderId = ...
let customerInfo = ...
let shippingAddress = ...
let orderLines =
unvalidatedOrder.Lines
// convert each line using `toValidatedOrderLine`
|> List.map (toValidatedOrderLine checkProductCodeExists)
...
let toOrderQuantity productCode quantity =
match productCode with
| Widget _ ->
quantity
|> int // convert decimal to int
|> UnitQuantity.create // to UnitQuantity
|> OrderQuantity.Unit // lift to OrderQuantity type
| Gizmo _ ->
quantity
|> KilogramQuantity.create // to KilogramQuantity
|> OrderQuantity.Kilogram // lift to OrderQuantity type
let toProductCode (checkProductCodeExists:CheckProductCodeExists) productCode =
productCode
|> ProductCode.create
|> checkProductCodeExists
// returns a bool :(
어댑터 함수 만들기 (Creating Function Adapters)
다음은 bool 반환 predicate(checkProductCodeExists) 및 확인할 값(productCode) 매개변수가 있는 명백한 구현입니다.
let convertToPassthru checkProductCodeExists productCode =
if checkProductCodeExists productCode then
productCode
else
failwith "Invalid Product Code"
val convertToPassthru :
checkProductCodeExists:('a -> bool) -> productCode:'a -> 'a
let predicateToPassthru f x =
if f x then
x
else
failwith "Invalid Product Code"
let predicateToPassthru errorMsg f x =
if f x then
x
else
failwith errorMsg
val predicateToPassthru : errorMsg:string -> f:('a -> bool) -> x:'a -> 'a
let toProductCode (checkProductCodeExists:CheckProductCodeExists) productCode =
// create a local ProductCode -> ProductCode function
// suitable for using in a pipeline
let checkProduct productCode =
// 에러 메세지 표현식
let errorMsg = sprintf "Invalid: %A" productCode
// 위의 에러 메세지를 인자로 제공
predicateToPassthru errorMsg checkProductCodeExists productCode
// assemble the pipeline
productCode
|> ProductCode.create
|> checkProduct
나머지 스텝 구현하기
type PriceOrder =
GetProductPrice // dependency
-> ValidatedOrder // input
-> Result<PricedOrder, PlaceOrderError> // output
type GetProductPrice = ProductCode -> Price
type PriceOrder =
GetProductPrice // dependency
-> ValidatedOrder // input
-> PricedOrder // output
let priceOrder : PriceOrder =
fun getProductPrice validatedOrder ->
let lines =
validatedOrder.Lines
|> List.map (toPricedOrderLine getProductPrice)
let amountToBill =
lines
// get each line price
|> List.map (fun line -> line.LinePrice)
// add them together as a BillingAmount
|> BillingAmount.sumPrices
let pricedOrder : PricedOrder = {
OrderId = validatedOrder.OrderId
CustomerInfo = validatedOrder.CustomerInfo
ShippingAddress = validatedOrder.ShippingAddress
BillingAddress = validatedOrder.BillingAddress
Lines = lines
AmountToBill = amountToBill
}
pricedOrder
let priceOrder : PriceOrder =
fun getProductPrice validatedOrder ->
failwith "not implemented"
/// Sum a list of prices to make a billing amount
/// Raise exception if total is out of bounds
// type GetProductPrice = ProductCode -> Price
let sumPrices prices =
let total = prices |> List.map Price.value |> List.sum
create total
toPricedOrderLine 함수는 이전에 본 것과 유사합니다. 한 줄만 변환하는 도우미 함수입니다.
/// Transform a ValidatedOrderLine to a PricedOrderLine
let toPricedOrderLine getProductPrice (line:ValidatedOrderLine) : PricedOrderLine =
let qty = line.Quantity |> OrderQuantity.value
let price = line.ProductCode |> getProductPrice
let linePrice = price |> Price.multiply qty
{
OrderLineId = line.OrderLineId
ProductCode = line.ProductCode
Quantity = line.Quantity
LinePrice = linePrice
}
module Price =
let multiply qty (Price p) =
create (qty * p)
승인 단계 구현
type HtmlString = HtmlString of string
type CreateOrderAcknowledgmentLetter =
PricedOrder -> HtmlString
type OrderAcknowledgment = {
EmailAddress : EmailAddress
Letter : HtmlString
}
type SendResult = Sent | NotSent
type SendOrderAcknowledgment =
OrderAcknowledgment -> SendResult
type AcknowledgeOrder =
CreateOrderAcknowledgmentLetter // dependency
-> SendOrderAcknowledgment // dependency
-> PricedOrder // input
-> OrderAcknowledgmentSent option // output
let acknowledgeOrder : AcknowledgeOrder =
fun createAcknowledgmentLetter sendAcknowledgment pricedOrder ->
let letter = createAcknowledgmentLetter pricedOrder
let acknowledgment = {
EmailAddress = pricedOrder.CustomerInfo.EmailAddress
Letter = letter
}
// if the acknowledgment was successfully sent,
// return the corresponding event, else return None
match sendAcknowledgment acknowledgment with
| Sent ->
let event = {
OrderId = pricedOrder.OrderId
EmailAddress = pricedOrder.CustomerInfo.EmailAddress
}
Some event
| NotSent ->
None
이벤트 생성하기
마지막으로 워크플로에서 반환할 이벤트를 만들기만 하면 됩니다.
요구 사항에 청구 가능 금액이 0보다 클 때만 청구 이벤트를 보내야 한다고 가정해 보겠습니다. 디자인은 다음과 같습니다.
/// Event to send to shipping context
type OrderPlaced = PricedOrder
/// Event to send to billing context
/// Will only be created if the AmountToBill is not zero
type BillableOrderPlaced = {
OrderId : OrderId
BillingAddress: Address
AmountToBill : BillingAmount
}
type PlaceOrderEvent =
| OrderPlaced of OrderPlaced
| BillableOrderPlaced of BillableOrderPlaced
| AcknowledgmentSent of OrderAcknowledgmentSent
type CreateEvents =
PricedOrder // input
-> OrderAcknowledgmentSent option // input (event from previous step)
-> PlaceOrderEvent list
// PricedOrder -> BillableOrderPlaced option
let createBillingEvent (placedOrder:PricedOrder) : BillableOrderPlaced option =
let billingAmount = placedOrder.AmountToBill |> BillingAmount.value
if billingAmount > 0M then
let order = {
OrderId = placedOrder.OrderId
BillingAddress = placedOrder.BillingAddress
AmountToBill = placedOrder.AmountToBill
}
Some order
else
None
이제 OrderPlaced 이벤트, Optional OrderAcknowledgementSent 이벤트 및 Optional BillableOrderPlaced가 있습니다.
어떻게 반환해야 합니까?
우리는 모든 것을 공통 유형으로 변환하기 위해 "최소 공배수" 접근 방식을 사용할 것입니다.
우리는 전부를 포함한 초이스 타입(PlaceOrderEvent)을 생성한 다음 그 리스트를 반환하기로 결정했습니다.
따라서 먼저 각 이벤트를 초이스 타입으로 변환해야 합니다.
OrderPlaced 이벤트의 경우 PlaceOrderEvent.OrderPlaced 생성자를 직접 사용할 수 있지만
OrderAcknowledgementSent 및 BillableOrderPlaced의 경우 선택 사항이므로 Option.map을 사용해야 합니다.
let createEvents : CreateEvents =
fun pricedOrder acknowledgmentEventOpt ->
let event1 =
pricedOrder
// convert to common choice type
|> PlaceOrderEvent.OrderPlaced
let event2Opt =
acknowledgmentEventOpt
// convert to common choice type
|> Option.map PlaceOrderEvent.AcknowledgmentSent
let event3Opt =
pricedOrder
|> createBillingEvent
// convert to common choice type
|> Option.map PlaceOrderEvent.BillableOrderPlaced
// return all the events how?
...
/// convert an Option into a List
let listOfOption opt =
match opt with
| Some x -> [x]
| None -> []
type OrderPlaced = PricedOrder
/// Event to send to billing context
/// Will only be created if the AmountToBill is not zero
type BillableOrderPlaced = {
OrderId : OrderId
BillingAddress: Address
AmountToBill : BillingAmount
}
type PlaceOrderEvent =
| OrderPlaced of OrderPlaced
| BillableOrderPlaced of BillableOrderPlaced
| AcknowledgmentSent of OrderAcknowledgmentSent
type CreateEvents =
PricedOrder // input
-> OrderAcknowledgmentSent option // input (event from previous step)
-> PlaceOrderEvent list // output
//<
let createEvents : CreateEvents =
fun pricedOrder acknowledgmentEventOpt ->
let events1 =
pricedOrder
// convert to common choice type
|> PlaceOrderEvent.OrderPlaced
// convert to list
|> List.singleton
let events2 =
acknowledgmentEventOpt
// convert to common choice type
|> Option.map PlaceOrderEvent.AcknowledgmentSent
// convert to list
|> listOfOption
let events3 =
pricedOrder
|> createBillingEvent
// convert to common choice type
|> Option.map PlaceOrderEvent.BillableOrderPlaced
// convert to list
|> listOfOption
// return all the events
[
yield! events1
yield! events2
yield! events3
]
'BackEnd' 카테고리의 다른 글
DDD 구현 : 오류 모델링 (0) | 2022.03.20 |
---|---|
함수 파이프라인 합성하기 with F# 2. 작은 파이프라인 조합하기 (0) | 2022.03.20 |
DDD 기능 구현을 위한 함수 이해하기 with F# (0) | 2022.03.19 |
파이프라인으로 워크플로 모델링하기 - 워크플로 합성 및 나머지 (0) | 2022.03.19 |
파이프라인으로 워크플로 모델링하기 - 함수 타입으로 워크플로 모델링, 이펙트 모델링 (0) | 2022.03.19 |