蒼時弦也
蒼時弦也
資深軟體工程師
發表於

Place Order 實作 Entity 部分 - Clean Architecture in Go

這篇文章是 Clean Architecture in Go 系列的一部分,你可以透過 Leanpub 提前閱讀內容。

Controller 和 UseCase 都有對應的實作後,我們還需要定義在這個系統中負責管理狀態的 Entity(實體)才算是有一個基礎的系統雛形。在這個例子中,我們會需要 Order 和 OrderItem 這兩種實體。

定義 Entity

在 Place Order 的情境中,我們會需要一個訂單來紀錄客戶名稱,以及一個的列表來紀錄使用者購買的項目,同時我們並不希望項目重複。

因此,我們可以做出如下的實作。

 1// internal/entity/orders/item.go
 2package orders
 3
 4type Item struct {
 5	name      string
 6	quantity  int
 7	unitPrice int
 8}
 9
10func (i *Item) Name() string {
11	return i.name
12}
13
14func (i *Item) Quantity() int {
15	return i.quantity
16}
17
18func (i *Item) UnitPrice() int {
19	return i.unitPrice
20}
 1// internal/entity/orders/order.go
 2package orders
 3
 4import "errors"
 5
 6var (
 7	ErrItemNameMustBeUnique = errors.New("item name must be unique")
 8)
 9
10type Order struct {
11	id           string
12	customerName string
13	items        []*Item
14}
15
16func New(id string, customerName string) *Order {
17	return &Order{
18		id:           id,
19		customerName: customerName,
20		items:        []*Item{},
21	}
22}
23
24func (o *Order) Id() string {
25	return o.id
26}
27
28func (o *Order) CustomerName() string {
29	return o.customerName
30}
31
32func (o *Order) Items() []*Item {
33	return o.items
34}
35
36func (o *Order) HasItem(name string) bool {
37	for _, item := range o.items {
38		if item.name == name {
39			return true
40		}
41	}
42
43	return false
44}
45
46func (o *Order) AddItem(name string, quantity int, unitPrice int) error {
47	if o.HasItem(name) {
48		return ErrItemNameMustBeUnique
49	}
50
51	o.items = append(o.items, &Item{
52		name:      name,
53		quantity:  quantity,
54		unitPrice: unitPrice,
55	})
56
57	return nil
58}

和 Controller 跟 UseCase 使用的結構(Struct)不同的地方在於所有欄位(Field)都是私有的,這是因為我們並不預期使用者透過我們封裝的方法以外的方式改變狀態,這樣才能確保每一個變動都有適當的檢查(如:AddItem 會做 HasItem 的確認)

這也是為什麼我們無法直接使用 ORM 產生的結構來作為 Entity 的理由,因為 ORM 產生的結構都是開放的,很可能在我們預期以外的情況下被修改,最終導致得到我們期望以外的結果。

更新 UseCase

原本的 UseCase 只是單純地將輸入轉換到輸出,有了 Entity 後就可以實際的使用 Entity 來進行處理,看起來就更接近真實的使用情境。

 1// internal/usecase/place_order.go
 2
 3// ...
 4func (u *PlaceOrder) Execute(ctx context.Context, input *PlaceOrderInput) (*PlaceOrderOutput, error) {
 5	order := orders.New(
 6		uuid.NewString(),
 7		input.Name,
 8	)
 9
10	for _, item := range input.Items {
11		err := order.AddItem(item.Name, item.Quantity, item.UnitPrice)
12		if err != nil {
13			return nil, err
14		}
15	}
16
17	out := &PlaceOrderOutput{
18		Id:    order.Id(),
19		Name:  order.CustomerName(),
20		Items: []PlaceOrderItem{},
21	}
22
23	for _, item := range order.Items() {
24		out.Items = append(out.Items, PlaceOrderItem{
25			Name:      item.Name(),
26			Quantity:  item.Quantity(),
27			UnitPrice: item.UnitPrice(),
28		})
29	}
30
31	return out, nil
32}

上述的實作看起來似乎只是增加了 orders.New() 並且展開輸入放到回傳的內容,似乎沒有太過於特別的地方。

然而,我們在 order.AddItem() 時因為會檢查是否有名稱上的重複,因此在 UseCase 發生的錯誤就能以各種形式回傳給使用者,類似下面的測試案例。

 1Feature: Place order
 2  Scenario: Cannot place order with same item name
 3    When make a POST request to "/orders"
 4    """
 5    {
 6      "name": "Aotoki",
 7      "items": [
 8        {
 9          "name": "Apple",
10          "quantity": 2,
11          "unit_price": 10
12        },
13        {
14          "name": "Apple",
15          "quantity": 3,
16          "unit_price": 5
17        }
18      ]
19    }
20    """
21    Then the response status code should be 400
22    And the response body should be "item name must be unique"

當我們傳入多個品項,但名稱重複的時候,就會在 Entity 的情境中被偵測到,並且告知使用者。這裡比較難以區分的是 Controller 接收輸入時的 Validation(驗證)以及操作中發生的錯誤,像是「不能小於 0」這樣的情境。

在 Controller 中會對此做檢查,是因為小於 0 不是一個合理的範圍。然而在 Entity 中處理時,會發生這樣的錯誤,會是因為 Decrement(1) 這樣的方法造成操作超出預期的範圍,即使錯誤訊息相同,仍有不同的意義。

封裝

Entity 的實作過程是一個不錯的封裝(Encapsulation)案例,我們希望物件可以很好的管理內部狀態,因此不允許直接存取這些欄位。同時透過實作 Getter 的方法,開放出允許存取的欄位以唯讀的方式取得。

若要改變狀態,那麼只能夠過設計好的方法操作,這樣一來使用者就不會用我們預期之外的方法使用,在 UseCase 進行操作的時候,就不容易出現為了方便或者理解錯誤,錯誤的修改物件狀態的情境。

同時,如果要拓展可以操作的行為,也會回到 Entity 上修改,如果對於 Entity 的角色跟定位有理解錯誤,也就更容易在程式碼審查(Code Review)階段被注意到。