본문 바로가기

BackEnd

타입으로 도메인 모델링하기 with F# - Aggregate (집합체)

반응형

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

Order 및 OrderLine 타입에 대해 자세히 살펴보겠습니다.

    type OrderLine = {
      OrderLineId : int
      Price : float
      // etc
      }

    type Order = {
      OrderLines : OrderLine list
      // etc
      }
첫째, Order는 Entity인가요, Value Object인가요? 분명히 그것은 Entity 입니다.
주문의 세부 사항은 시간이 지남에 따라 변경될 수 있지만 같은 Order입니다. (주문 변경)
OrderLine은 어떻습니까?
예를 들어 특정 주문 라인의 수량을 변경해도 여전히 동일한 주문 라인입니까?
대부분의 디자인에서는 시간이 지남에 따라 수량이나 가격이 변경되더라도 여전히 동일한 주문 라인이라고 말하는 것이 합리적입니다.
따라서 OrderLine도 자체 식별자가 있는 엔터티입니다.

OrderLine을 변경하면 Order도 변경되나요?

이 경우 대답은 '예'임이 분명합니다.

OrderLine을 변경하면 전체 Order도 변경됩니다.

사실, 데이터 구조를 변경할 수 없기 때문에 이를 피할 수 없습니다.

변경할 수 없는 OrderLine을 포함하는 변경할 수 없는 주문이 있는 경우 주문 라인 중 하나의 복사본을 만드는 것만으로는 Order의 복사본을 만들 수 없습니다.

Order에 포함된 OrderLine을 변경하려면 OrderLine 수준이 아니라 Order 수준에서 변경해야 합니다.

예를 들어 주문 라인의 가격을 업데이트하기 위한 의사 코드는 다음과 같습니다.

​ 	​/// 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 =
​ 	
​ 	  ​// 1. find the line to change using the orderLineId​
​ 	  ​let​ orderLine = order.OrderLines |> findOrderLine orderLineId
​ 	
​ 	  ​// 2. make a new version of the OrderLine with the new price​
​ 	  ​let​ newOrderLine = {orderLine ​with​ Price = newPrice}
​ 	
​ 	  ​// 3. create a new list of lines, replacing​
​ 	  ​//    the old line with the new line​
​ 	  ​let​ newOrderLines =
​ 	    order.OrderLines |> replaceOrderLine orderLineId newOrderLine
​ 	
​ 	  ​// 4. make a new version of the entire order, replacing​
​ 	  ​//    all the old lines with the new lines​
       // 가장 중요합니다! 새로운 order를 리턴합니다!
​ 	  ​let​ newOrder = {order ​with​ OrderLines = newOrderLines}
​ 	
​ 	  ​// 5. return the new order​
​ 	  newOrder





      let findOrderLine orderLineId (lines:OrderLine list) =
        lines |> List.find (fun ol -> ol.OrderLineId = orderLineId )

      let replaceOrderLine orderLineId newOrderLine lines = 
        lines // no implementation!
함수의 출력인 최종 결과는 새 라인 목록을 포함하는 새 주문이며, 라인 중 하나에 새 가격이 입력됩니다.
불변성이 데이터 구조에 파급 효과를 일으키는 것을 볼 수 있습니다.
따라서 하나의 하위 수준 구성 요소를 변경하면 상위 수준 구성 요소도 강제로 변경할 수 있습니다.
따라서 "하위 항목"(OrderLine) 중 하나를 변경하더라도 항상 Order 수준에서 작업해야 합니다.
이것은 매우 일반적인 상황입니다. 각각 고유한 ID가 있는 엔터티 컬렉션과 이를 포함하는 "최상위" 엔터티가 있습니다.
DDD 용어로 이와 같은 엔터티의 모음을 집계라고 하고 최상위 엔터티를 Aggregate Root라고 합니다.
이 경우 집계는 Order와 OrderLine의 컬렉션으로 구성되며 Aggregate Root는 Order 자신입니다.

Aggregates 는 일관성과 불변 규칙을 강제합니다. (Consistency and Invariants)

Aggregate는 데이터가 업데이트될 때 중요한 역할을 합니다.

 

Aggregate는 일관성 경계 역할을 합니다. (일관성 - 여러 데이터 간의 일치, 정합성)

Aggregate의 한 부분이 업데이트되면 일관성을 보장하기 위해 다른 부분도 업데이트해야 할 수 있습니다.

예를 들어, 최상위 주문에 추가 필드인 "총 가격"이 저장되도록 이 디자인을 확장할 수 있습니다.

분명히 라인 중 하나가 가격을 변경하면 데이터 일관성을 유지하기 위해 Aggregate도 업데이트해야 합니다.

이것은 위의 changeOrderLinePrice 함수에서 수행됩니다.

일관성을 유지하는 방법을 "알고 있는" 유일한 구성 요소는 최상위 주문(Aggregate Root)뿐이므로 이것이 라인 수준이 아닌 주문 수준에서 모든 업데이트를 수행하는 또 다른 이유입니다.

 

Aggregate는 불변 규칙이 적용되는 곳이기도 합니다. (Invariant - 비즈니스 규칙)

모든 주문에 하나 이상의 주문 라인이 있다는 규칙이 있다고 가정해 보겠습니다.

그런 다음 여러 주문 라인을 삭제하려고 하면 집계에서 한 라인만 남아 있을 때 삭제 시도 시 오류가 발생하는지 확인합니다.

 

Aggregate 참조

Order과 연관된 고객 정보가 필요하다고 가정해 보겠습니다. 다음과 같이 고객을 주문 필드로 추가하고 싶을 수 있습니다.
​ 	​type​ Order = {
​ 	  OrderId : OrderId
​ 	  Customer : Customer  ​// info about associated customer​
​ 	  OrderLines : OrderLine ​list​
​ 	  ​// etc​
​ 	  }
그러나 불변성의 파급 효과에 대해 생각해 보십시오.
고객의 일부를 변경하면 주문도 변경해야 합니다. 그것이 정말로 우리가 원하는 것입니까?

훨씬 더 나은 디자인은 전체 고객 레코드 자체가 아니라 고객에 대한 참조를 저장하는 것입니다.

즉, 다음과 같이 주문 타입에 CustomerId를 저장합니다.

​ 	​type​ Order = {
​ 	  OrderId : OrderId
​ 	  CustomerId : CustomerId  ​// reference to associated customer​
​ 	  OrderLines : OrderLine ​list​
​ 	  ​// etc​
​ 	  }
고객에 대한 전체 정보가 필요할 때 주문에서 CustomerId를 가져온 다음 주문의 일부로 로드하는 대신, 데이터베이스에서 관련 고객 데이터를 별도로 로드합니다.
즉, 고객과 주문은 별개의 독립적인 aggregates 입니다. 각각 자신의 내부 일관성에 대한 책임이 있으며 그들 사이의 유일한 연결은 루트 개체의 식별자를 통해 이루어집니다.

 

이것은 Aggregates의 또 다른 중요한 측면으로 이어집니다.

Aggregates는 persistence의 기본 단위입니다.

데이터베이스에서 개체를 로드하거나 저장하려면 전체 집계를 로드하거나 저장해야 합니다.
각 데이터베이스 트랜잭션은 단일 집계로 작동해야 하며 다중 집계 또는 교차 집계 경계를 포함하지 않아야 합니다.
 
마찬가지로 객체를 직렬화하여 유선으로 보내려면 항상 전체 Aggregates를 보내야 하고 일부는 보내지 않습니다.
 
명확히 하자면 Aggregates는 단순한 엔터티 모음이 아닙니다.
예를 들어 고객 목록은 엔터티 모음이지만
  • 최상위 엔터티 루트가 아니고 
  • 일관성 경계가 아니면 DDD "Aggregates"가 아닙니다.
 
다음은 도메인 모델에서 집계의 중요한 역할에 대한 요약입니다.
 
  • 집계는 최상위 엔터티가 "루트" 역할을 하는 단일 단위로 처리될 수 있는 도메인 개체의 모음입니다.
  • 집계 내부의 개체에 대한 모든 변경 사항은 최상위 수준을 통해 루트에 적용되어야 하며
  • 집계는 일관성 경계 역할을 하여 집계 내부의 모든 데이터가 동시에 올바르게 업데이트되도록 합니다.
  • 집계는 지속성, 데이터베이스 트랜잭션 및 트랜잭션의 원자성 단위입니다.
Aggregates 정의는 디자인 프로세스의 중요한 부분입니다.
때로는 함께 사용되는 엔터티가 동일한 집계의 일부(OrderLine 및 Order)이고
때로는 그렇지 않은 경우(Customer 및 Order)입니다.
도메인 전문가와의 협업이 중요한 이유가 여기에 있습니다.
전문가만이 엔터티와 일관성 경계 간의 관계를 이해하는 데 도움이 될 수 있습니다.
 
우리는 모델링 과정에서 많은 Aggregates를 보게 될 것입니다.
 

지금까지 공부한 DDD 용어는 다음과 같습니다.

  • Value Object는 ID가 없는 도메인 개체입니다. 동일한 데이터를 포함하는 두 개의 Value Object는 동일한 것으로 간주됩니다. Value Object는 변경 불가능해야 합니다. 일부가 변경되면 다른 Value Object가 됩니다. Value Object의 예로는 이름, 주소, 위치, 돈 및 날짜가 있습니다.
  • Entity는 속성이 변경되어도 지속되는 고유한 ID를 가진 도메인 개체입니다. Entity 개체에는 일반적으로 ID 또는 키 필드가 있으며 동일한 ID/키를 가진 두 개체는 동일한 개체로 간주됩니다. Entity는 일반적으로 문서와 같이 수명과 변경 이력이 있는 도메인 개체를 나타냅니다. Entity의 예로는 고객, 주문, 제품 및 송장이 있습니다.
  • Aggregate는 도메인의 일관성을 보장하고 데이터 트랜잭션의 원자성 단위로 사용되며, 단일 구성 요소로 처리되는 관련된 Entity의 모음입니다. 다른 Entity는 "Root"로 알려진 Aggregate의 "최상위" 구성원 ID인 식별자로만 Aggregate를 참조해야 합니다.
 

타입 정의를 도메인 정의 문서로 활용하기.

지금까지 코드로 어떻게 문서를 만들 것인지에 대해 배웠습니다.

답은 타입 시스템을 적절히 활용하는 것입니다.

타입 시스템을 활용하여 도메인 전문가 및 다른 협력자들이 도메인의 요구 사항을 캡처할 수 있나요?

개발자가 아니라면 AND(레코드 타입), OR(초이스 타입, | ), Process(함수 화살표) 세 가지를 배우면 되지만, 기존 프로그래밍 언어보다 훨씬 읽기 쉬울 것입니다.

(빠진 내용은 도메인의 무결성과 일관성, 상태에 대한 내용입니다. 이는 추후 다룹니다.)

 

 

총정리

분명히, 많은 세부 사항이 여전히 구체화되어야 하지만, 이제 이를 수행하는 프로세스가 명확해졌습니다.
module PuttingItAllTogether = 



    (*>PuttingItAllTogether1 
    // 네임스페이스로 바운디드 컨텍스트를 나타냅니다!
    namespace OrderTaking.Domain 

    // types follow
    <*)
    
    //>PuttingItAllTogether2 
    // Product code related
    
    // Simple type (도메인 VO)
    // 이것들은 모두 값 개체이며 식별자가 필요하지 않습니다.
    
    
    type WidgetCode = WidgetCode of string
        // constraint: starting with "W" then 4 digits
    type GizmoCode = GizmoCode of string
        // constraint: starting with "G" then 3 digits
    type ProductCode = 
        | Widget of WidgetCode 
        | Gizmo of GizmoCode 

    // Order Quantity related
    type UnitQuantity = UnitQuantity of int
    type KilogramQuantity = KilogramQuantity of decimal
    type OrderQuantity = 
        | Unit of UnitQuantity 
        | Kilos of KilogramQuantity
    //<

    //>PuttingItAllTogether3 
    
    // 엔터티
    
    // 주문은 변경될 때 유지되는 ID(식별자)가 있으므로 엔터티로 모델링해야 합니다. 
    // ID가 string인지 int인지 Guid인지 알 수 없지만 필요하다는 것을 알고 있으므로 
    // 지금은 Undefined를 사용합시다. 다른 식별자도 같은 방식으로 처리합니다.

    type OrderId = Undefined
    type OrderLineId = Undefined
    type CustomerId = Undefined
    //<

	// 이제 주문과 해당 구성 요소를 스케치할 수 있습니다.
	// VO는 엔터티의 필드 역할을 합니다.
    //>PuttingItAllTogether4 
    type CustomerInfo = Undefined
    type ShippingAddress = Undefined
    type BillingAddress = Undefined
    type Price = Undefined
    type BillingAmount = Undefined

    type Order = {
        Id : OrderId             // id for entity
        CustomerId : CustomerId  // customer reference
        ShippingAddress : ShippingAddress 
        BillingAddress : BillingAddress 
        OrderLines : OrderLine list
        AmountToBill : BillingAmount
        }
	// and는 아래 나오는 타입을 참조하기 위한 F# 키워드입니다.
    and OrderLine = {
        Id : OrderLineId  // id for entity
        OrderId : OrderId
        ProductCode : ProductCode 
        OrderQuantity : OrderQuantity 
        Price : Price
        }
    //<

	// 이제 워크플로로 마무리하겠습니다. 
    // 워크플로에 대한 입력인 UnvalidatedOrder는 "as is" 주문 양식에서 작성되므로 
    // int 및 string과 같은 primitive 타입만 포함됩니다.

    //>PuttingItAllTogether5
    type UnvalidatedOrder = {
       OrderId : string
       CustomerInfo : DotDotDot
       ShippingAddress : DotDotDot
       //...
       }
    //<

	// 워크플로의 출력에는 두 가지 유형이 필요합니다. 
    
    // 첫 번째는 워크플로가 성공한 경우의 이벤트 유형입니다.
    //>PuttingItAllTogether6a
    type PlaceOrderEvents = {
       AcknowledgmentSent : DotDotDot
       OrderPlaced : DotDotDot
       BillableOrderPlaced : DotDotDot
       }
    //<

	// 두 번째는 워크플로가 실패할 때의 오류 유형입니다.
    //>PuttingItAllTogether6b
    type PlaceOrderError =
       | ValidationError of ValidationError list
       | DotDotDot  // other errors

    and ValidationError = { 
        FieldName : string
        ErrorDescription : string
        }
    //<

    //>PuttingItAllTogether7
    // 마지막으로 주문 배치 워크플로를 나타내는 최상위 기능을 정의할 수 있습니다.
    /// The "Place Order" process
    type PlaceOrder = 
        UnvalidatedOrder -> Result<PlaceOrderEvents,PlaceOrderError>
    //<

 

 

 

반응형