해당 장의 요약 : 비즈니스 제약 사항을 주석이 아니라 타입 시스템을 이용해 작성하라! 타입 체킹을 통해 불필요한 테스트코드 작성을 피할 수 있다.
지금까지 F# 타입 시스템을 사용한 도메인 모델링의 기본 사항을 살펴보았습니다.
우리는 도메인을 나타면서도 컴파일 가능하고 구현을 안내하는 데 사용할 수 있는 풍부한 타입 세트를 구축했습니다.
- 우리는 UnitQuantity가 1에서 1000 사이여야 함을 알고 있습니다.. 우리 코드에서 이것을 여러 번 확인해야 합니까, 아니면 항상 참이라고 믿을 수 있습니까?
- 주문에는 항상 하나 이상의 주문 라인이 있어야 합니다.
- 주문은 배송 부서로 보내지기 전에 확인된 배송 주소가 있어야 합니다.
- 주문에 대해 청구할 총 금액은 개별 라인의 합계여야 합니다. 합계가 다르면 데이터가 일치하지 않습니다.
- 주문이 접수되면 해당 송장을 생성해야 합니다. 주문이 있지만 송장이 없으면 데이터가 일치하지 않습니다.
- 할인 쿠폰 코드를 주문과 함께 사용하는 경우 쿠폰 코드를 사용한 것으로 표시해야 다시 사용할 수 없습니다. 주문이 해당 바우처를 참조하지만 바우처가 사용된 것으로 표시되지 않은 경우 데이터가 일치하지 않습니다.
타입 시스템에서 캡처할 수 있는 정보가 많을수록 필요한 문서가 줄어들고 코드가 올바르게 구현될 가능성이 높아집니다.
필드의 무결성
필드 모델링 시에도 primitive value가 아니라 WidgetCode, UnitQuantity와 같은 도메인 중심 타입으로 표현해야 함을 배웠습니다.
- OrderQuantity는 부호 있는 정수로 표시될 수 있지만 비즈니스에서 음수 또는 40억을 원할 가능성은 거의 없습니다.
- CustomerName은 문자열로 표시될 수 있지만 이것이 탭 문자나 줄 바꿈을 포함해야 함을 의미하지는 않습니다.
type WidgetCode = WidgetCode of string // starting with "W" then 4 digits
type UnitQuantity = UnitQuantity of int // between 1 and 1000
type KilogramQuantity = KilogramQuantity of decimal // between 0.05 and 100.00
type UnitQuantity = private UnitQuantity of int
// ^ private constructor
주의! 아래 함수는 타입이 아니라 implementation 입니다.
// define a module with the same name as the type
module UnitQuantity =
/// Define a "smart constructor" for UnitQuantity
/// int -> Result<UnitQuantity,string>
let create qty =
if qty < 1 then
// failure
Error "UnitQuantity can not be negative"
else if qty > 1000 then
// failure
Error "UnitQuantity can not be more than 1000"
else
// success -- construct the return value
Ok (UnitQuantity qty)
이전 버전의 F#과의 호환성
제네릭이 아닌 타입과 이름이 같은 모듈은 v4.1(VS2017) 이전의 F# 버전에서 오류를 일으키므로 다음과 같이 CompilationRepresentation 특성을 포함하도록 모듈 정의를 변경해야 합니다.
type UnitQuantity = ...
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
module UnitQuantity =
...
/// Return the wrapped value
let value (UnitQuantity qty) = qty
let unitQty = UnitQuantity 1
// ^ The union cases of the type 'UnitQuantity'
// are not accessible
let unitQtyResult = UnitQuantity.create 1
match unitQtyResult with
| Error msg ->
printfn "Failure, Message is %s" msg
| Ok uQty ->
printfn "Success. Value is %A" uQty
/////// UnitQuantity.value에 unitQuantity값을 전달해 Int 값을 빼옵니다.
let innerValue = UnitQuantity.value uQty
printfn "innerValue is %i" innerValue
측정 단위 (Units of Measure)
[<Measure>]
type kg
[<Measure>]
type m
let fiveKilos = 5.0<kg>
let fiveMeters = 5.0<m>
모든 SI 단위에 대해 측정 유형을 정의할 필요는 없습니다. Microsoft.FSharp.Data.UnitSystems.SI 네임스페이스에서 사용할 수 있습니다.
// compiler error
fiveKilos = fiveMeters
// ^ Expecting a float<kg> but given a float<m>
let listOfWeights = [
fiveKilos
fiveMeters // <-- compiler error
// The unit of measure 'kg'
// does not match the unit of measure 'm'
]
type KilogramQuantity = KilogramQuantity of decimal<kg>
타입 시스템에 불변(Invariant) 조건 적용 - 비즈니스 제약사항
type NonEmptyList<'a> = {
First: 'a
Rest: 'a list
}
주 : 타입스크립트의 경우도 해당 타입 사용이 가능합니다. 구현을 찾아보세요!
type Order = {
...
OrderLines : NonEmptyList<OrderLine>
...
}
이 변경으로 "주문에는 항상 하나 이상의 주문 라인이 있습니다"라는 제약 조건이 이제 자동으로 시행됩니다.
코드를 자체 문서화하고 요구 사항에 대한 단위 테스트를 작성할 필요가 없습니다.
타입 시스템에서 비즈니스 규칙 파악하기
타입 시스템만 사용하여 비즈니스 규칙을 문서화할 수 있습니까?
- 보안 침해를 방지하기 위해 확인된 이메일 주소로만 비밀번호 재설정 이메일을 보내야 합니다.
- 확인 이메일은 확인되지 않은 이메일 주소로만 보내야 합니다(기존 고객에게 스팸 발송을 방지하기 위해).
type CustomerEmail = {
EmailAddress : EmailAddress
IsVerified : bool
}
- 첫째, IsVerified 플래그를 설정하거나 설정 해제해야 하는 시기와 이유가 명확하지 않습니다. 예를 들어 고객의 이메일 주소가 변경된 경우 다시 false로 설정해야 합니다(새 이메일이 아직 확인되지 않았기 때문에). 그러나 디자인의 어떤 것도 그 규칙을 명시하지 않습니다.
- 둘째, 개발자가 이메일이 변경될 때 실수로 이 작업을 잊어버리거나 더 심하게는 규칙을 완전히 인식하지 못할 가능성이 있습니다.(어딘가의 일부 주석에 숨겨져 있기 때문입니다).
보안 침해의 가능성도 있습니다. 개발자는 확인되지 않은 이메일에 대해서도 실수로 플래그를 true로 설정하여 비밀번호 재설정 이메일을 확인되지 않은 주소로 보내는 코드를 작성할 수 있습니다.
type CustomerEmail =
| Unverified of EmailAddress
| Verified of EmailAddress
(주 : Verified 케이스는 VerifiedEmailAddress를 통해서만 생성할 수 있음을 의미합니다.
해당 책의 예제에서는, CustomerEmail이라는 최상위 모듈에서,
create라는 함수로 EmailAddress혹은 VerifiedEmailAddress Unverified, Verified를 리턴합니다.
value라는 함수로 Unverified인지, Verified 내부의 EmailAddress혹은 VerifiedEmailAddress 내부의 string 값을 리턴합니다 (...)
type CustomerEmail =
| Unverified of EmailAddress
| Verified of VerifiedEmailAddress // different from normal EmailAddress
type SendPasswordResetEmail = VerifiedEmailAddress -> ...
type Contact = {
Name: Name
Email: EmailContactInfo
Address: PostalContactInfo
}
type Contact = {
Name: Name
Email: EmailContactInfo option
Address: PostalContactInfo option
}
- An email address only
- A postal address only
- Both an email address and a postal address
type BothContactMethods = {
Email: EmailContactInfo
Address : PostalContactInfo
}
type ContactInfo =
| EmailOnly of EmailContactInfo
| AddrOnly of PostalContactInfo
| EmailAndAddr of BothContactMethods
type Contact = {
Name: Name
ContactInfo : ContactInfo
}
Making Illegal States Unrepresentable in Our Domain
- UnvalidatedAddress 및 ValidatedAddress의 두 가지 고유한 타입을 만듭니다.
- ValidatedAddress에 private 생성자를 제공합니다
- 주소 유효성 검사 서비스에서만 ValidateAddress를 생성할 수 있는지 확인합니다. (패턴!)
type UnvalidatedAddress = ...
// 유효한 타입은 private 생성자로만 가능!
type ValidatedAddress = private ...
type AddressValidationService =
UnvalidatedAddress -> ValidatedAddress option
type UnvalidatedOrder = {
...
ShippingAddress : UnvalidatedAddress
...
}
type ValidatedOrder = {
...
ShippingAddress : ValidatedAddress
...
}
이제 테스트를 작성하지 않고도 ValidatedOrder의 주소가 주소 유효성 검사 서비스에 의해 처리되었음을 보장할 수 있습니다!
Consistency
- 주문 총액은 개별 라인의 합계여야 합니다. 합계가 다르면 데이터가 일치하지 않습니다.
- 주문이 접수되면 해당 송장을 생성해야 합니다. 주문이 있지만 송장이 없으면 데이터가 일치하지 않습니다.
- 할인 쿠폰 코드를 주문과 함께 사용하는 경우 쿠폰 코드를 사용한 것으로 표시해야 다시 사용할 수 없습니다. 주문이 해당 바우처를 참조하지만 바우처가 사용된 것으로 표시되지 않은 경우 데이터가 일치하지 않습니다.
Consistency Within a Single Aggregate - 단일 집합체 내 일관성
주문에 대한 총액이 개별 라인의 합계여야 한다고 가정해 보겠습니다.
추가 데이터를 유지해야 하는 경우(예: 루트 엔터티-Aggregate인 Order에 저장된 추가 AmountToBill 필드) 동기화 상태를 유지해야 합니다.
이 경우 OrderLine 중 하나가 업데이트되면 데이터 일관성을 유지하기 위해 합계도 업데이트되어야 합니다.
일관성을 유지하는 방법을 "알고 있는" 유일한 구성 요소는 Order라는 것이 분명합니다.
이것이 라인 수준이 아닌 Order 수준에서 모든 업데이트를 수행하는 좋은 이유입니다.
Order은 일관성 경계(consistency boundary)를 적용하는 Aggregate입니다.
다음은 이것이 어떻게 작동하는지 보여주는 몇 가지 코드입니다.
- 1. Order 자체를 새로 만듬
- 2. Order 내에서 필요한 계산은 전부 다 수행함
module Consistency =
type OrderLine = {
OrderLineId : int
Price : float
// etc
}
type Order = {
OrderLines : OrderLine list
AmountToBill : float
// etc
}
let findOrderLine orderLineId (lines:OrderLine list) =
lines |> List.find (fun ol -> ol.OrderLineId = orderLineId )
let replaceOrderLine orderLineId newOrderLine lines =
lines // no implementation! - 구현 생략 더미
//>Consistency1
/// We pass in three parameters:
/// * the top-level order
/// * the id of the order line we want to change
/// * the new price
let changeOrderLinePrice order orderLineId newPrice =
// find orderLine in order.OrderLines using orderLineId
let orderLine = order.OrderLines |> findOrderLine orderLineId
// make a new version of the OrderLine with new price
let newOrderLine = {orderLine with Price = newPrice}
// create new list of lines, replacing old line with new line
let newOrderLines =
order.OrderLines |> replaceOrderLine orderLineId newOrderLine
// make a new AmountToBill
let newAmountToBill = newOrderLines |> List.sumBy (fun line -> line.Price)
// make a new version of the order with the new lines
let newOrder = {
order with
OrderLines = newOrderLines
AmountToBill = newAmountToBill
}
// return the new order
newOrder
//<
Consistency Between Different Contexts (다른 컨텍스트 간 일관성)
서로 다른 컨텍스트 간에 조정(coordinate)해야 하는 경우 어떻게 해야 합니까? 위 목록의 두 번째 예를 살펴보겠습니다.
주문이 접수되면 청구서(송장-invoice)을 생성해야 합니다. 주문이 있지만 invoice가 없으면 데이터가 일치하지 않습니다.
invoice는 주문(order-taking ) 도메인이 아닌 청구(billing) 도메인의 일부입니다.
그것은 우리가 다른 도메인에 접근하여 그 개체를 조작해야 한다는 것을 의미합니까?
물론 아닙니다. 각 바운디드 컨텍스트를 격리 및 분리된 상태로 유지해야 합니다.
다음과 같이 청구 컨텍스트의 공개 API를 사용하는 것은 어떻습니까?
Ask billing context to create invoice
If successfully created:
create order in order-taking context
- 한 가지 옵션은 아무것도 하지 않는 것입니다. 그러면 고객은 무료로 물건을 받고 기업은 비용을 상각해야 합니다. 오류가 드물고 비용이 적은 경우(커피숍에서와 같이) 완벽하게 적절한 솔루션이 될 수 있습니다.
- 또 다른 옵션은 메시지가 손실되었음을 감지하고 다시 보내는 것입니다. 이것은 기본적으로 조정 프로세스가 하는 일입니다. 두 데이터 세트를 비교하고 일치하지 않으면 오류를 수정합니다.
- 세 번째 옵션은 이전 작업을 "실행 취소"하거나 오류를 수정하는 보상 작업을 만드는 것입니다. 주문을 받는 시나리오에서 이는 주문을 취소하고 고객에게 상품을 다시 보내달라고 요청하는 것과 같습니다! 더 현실적으로 보상 조치는 주문 오류 수정 또는 환불 처리와 같은 작업을 수행하는 데 사용될 수 있습니다.
세 가지 경우 모두 경계 컨텍스트 간에 엄격한 조정이 필요하지 않습니다.
동일한 컨텍스트에서 Aggregates 간의 일관성
Start transaction
Add X amount to accountA
Remove X amount from accountB
Commit transaction
Account가이 Account 집계로 표시되는 경우 동일한 트랜잭션에서 두 개의 다른 Aggregate를 업데이트합니다.
그것이 반드시 문제는 아니지만 도메인에 대한 더 깊은 통찰력을 얻기 위해 리팩토링할 수 있는 단서가 될 수 있습니다.
예를 들어, 이와 같은 경우 트랜잭션에는 고유한 식별자가 있는 경우가 많으며 이는 자체적으로 DDD 엔터티임을 의미합니다.
그렇다면 왜 그렇게 모델링하지 않습니까?
type MoneyTransfer = {
Id: MoneyTransferId
ToAccount : AccountId
FromAccount : AccountId
Amount: Money
}
동일한 데이터에 대해 동작하는 여러 Aggregates (integrity constraints)
요약
- 무결성은 코드의 비즈니스 규칙, 일관성은 데이터 간의 팩트 일치를 말합니다.
- 스마트 생성자와 Make Illegal State Unrepresentable(가능한 상태를 모두 코드로 표현)을 통해 코드만으로 많은 무결성 규칙을 확인할 수 있다.
- 즉 런타임 검사와 테스트 케이스, 문서화가 덜 필요하다.
- 바운디드 컨텍스트 간 협력은 최종 일관성을 기반으로 설계한다.
'BackEnd' 카테고리의 다른 글
파이프라인으로 워크플로 모델링하기 - 함수 타입으로 워크플로 모델링, 이펙트 모델링 (0) | 2022.03.19 |
---|---|
파이프라인으로 워크플로 모델링하기 - 개요 및 상태 머신 (0) | 2022.03.19 |
타입으로 도메인 모델링하기 with F# - Aggregate (집합체) (0) | 2022.03.18 |
타입으로 도메인 모델링하기 with F# - Value Object, Entitiy (0) | 2022.03.18 |
타입으로 도메인 모델링하기 with F# - 단순, 복합 타입과 함수 (0) | 2022.03.18 |