---
title: "Lookup Order 功能 - Clean Architecture in Go"
date: 2025-02-28T00:00:00+08:00
publishDate: 2025-02-28T00:00:00+08:00
lastmod: 2024-10-07T20:05:43+08:00
tags: ["Golang","Clean Architecture","架構","經驗"]
series: "clean-architecture-in-go"
toc: true
permalink: "https://blog.aotoki.me/posts/2025/02/28/clean-architecture-in-go-lookup-order/"
language: "zh-tw"
---


基於 Place Order 的實作，我們的專案已經有一個不錯的雛形可以在相當容易擴充的狀況下繼續進行。然而，如果只能建立訂單仍不足以驗證功能完善，因此我們接下來要加入 Lookup Order 來確認是否順利的將資料儲存到記憶體中。

<!--more-->

## 擴展 API{#extend-api}

原本我們利用 `oapi-codegen` 產生了 `POST /orders` 這個端點（Endpoint），接下來我們需要更新一下文件，加入 `GET /orders/{orderId}` 這個新的端點用於 Lookup Order 的實作。

```yaml
# ...
paths:
  /orders/{orderId}:
    get:
      summary: Lookup an order
      operationId: lookupOrder
      tags:
        - order
      parameters:
        - in: path
          name: orderId
          required: true
          description: The UUID of order
          schema:
            type: string
            example: 2ef749d9-b25e-49df-8ff3-54f3873fffb8
      responses:
        200:
          description: Order found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LookupOrderResponse'
# ...
components:
  schemas:
# ....
    LookupOrderResponse:
      type: object
      required:
        - id
        - name
        - items
      properties:
        id:
          type: string
          example: 2ef749d9-b25e-49df-8ff3-54f3873fffb8
        name:
          type: string
          example: Aotoki
        items:
          type: array
          minimum: 1
          items:
            $ref: '#/components/schemas/OrderItem'
# ..
```

基本上跟我們在處理 Place Order 的方式相同，然而 `LookupOrderResponse` 在這裡選擇跟 `PlaceOrderResponse` 重複的原因，是考量到這兩個行為的回傳並不一定會在未來相同，這跟實作時，不要太快嘗試消除重複的想法是類似的。

接下來，在 `internal/api/rest/lookup_order.go` 實作新產生的方法，我們就可以接續到 UseCase 的處理。

```go
package rest

// ...

func (api *Api) LookupOrder(w http.ResponseWriter, r *http.Request, orderId string) {
	out, err := api.LookupOrderUsecase.Execute(r.Context(), &usecase.LookupOrderInput{
		Id: orderId,
	})

	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	res := buildLookupOrderResponse(out)
	if err := json.NewEncoder(w).Encode(res); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
}

func buildLookupOrderResponse(out *usecase.LookupOrderOutput) *LookupOrderResponse {
	outItems := make([]OrderItem, 0, len(out.Items))
	for _, item := range out.Items {
		outItems = append(outItems, OrderItem{
			Name:      item.Name,
			Quantity:  float32(item.Quantity),
			UnitPrice: float32(item.UnitPrice),
		})
	}

	return &LookupOrderResponse{
		Id:    out.Id,
		Name:  out.Name,
		Items: outItems,
	}
}
```

## 實作 UseCase {#implement-usecase}

UseCase 處理上跟 Place Order 大致上相同。因為原本的 Repository 介面並沒有描述如何讀取資料，因此我們需要先擴充 `OrderRepository` 的方法。

```go
type OrderRepository interface {
	Find(ctx context.Context, id string) (*orders.Order, error)
	Save(ctx context.Context, order *orders.Order) error
}
```

基於 Golang 在 `io.Reader` 的設計，還能透過組合多個介面提供像是 `io.ReadWriter` 等不同情境，在規劃介面時也需要仔細思考是否附加太多方法，這可能會造成測試上的不便。

有了 `Find()` 方法後，我們就可以實作一個取出特定 `Order` 的 UseCase 來滿足這個需求。

```go
package usecase

import "context"

type LookupOrderItem struct {
	Name      string
	Quantity  int
	UnitPrice int
}

type LookupOrderInput struct {
	Id string
}

type LookupOrderOutput struct {
	Id    string
	Name  string
	Items []LookupOrderItem
}

type LookupOrder struct {
	orders OrderRepository
}

func NewLookupOrder(orders OrderRepository) *LookupOrder {
	return &LookupOrder{
		orders: orders,
	}
}

func (u *LookupOrder) Execute(ctx context.Context, input *LookupOrderInput) (*LookupOrderOutput, error) {
	order, err := u.orders.Find(ctx, input.Id)
	if err != nil {
		return nil, err
	}

	out := &LookupOrderOutput{
		Id:    order.Id(),
		Name:  order.CustomerName(),
		Items: []LookupOrderItem{},
	}

	for _, item := range order.Items() {
		out.Items = append(out.Items, LookupOrderItem{
			Name:      item.Name(),
			Quantity:  item.Quantity(),
			UnitPrice: item.UnitPrice(),
		})
	}

	return out, nil
}
```

接下來，我們繼續把 Repository 實作到裡面，就可以讓 Lookup Order 功能正常運作。

## 實作 Repository{#implement-repository}

Repository 的部分基本上沒有太大需要修改，然而我們會遇到「找不到」的情境，因此需要先在 `internal/entity/orders/order.go` 中加入新的錯誤類型。

```go
// ...
var (
	ErrItemNameMustBeUnique = errors.New("item name must be unique")
	ErrOrderNotFound        = errors.New("order not found")
)
// ...
```

在這裡選擇在 Entity 中定義錯誤，因為「訂單不存在」如果在 Repository 中定義的話，UseCase 就必須依賴於 Repository 上。同時，這個錯誤的知識存在於訂單相關的實體，也會比在 Repository 中合理不少。

> 撰寫本系列時，仍然沒有找到非常優秀的設計方式，以 Clean Architecture 的思考方式來看，在 Entity 或 UseCase 會合理不少。

在定義好錯誤後，就可以更新 `internal/repository/in_memory_orders.go` 來增加 `Find()` 的實作。

```go
package repository

// ...

type InMemoryOrderRepository struct {
	orders map[string]InMemoryOrderSchema
}

// ...

func (r *InMemoryOrderRepository) Find(ctx context.Context, id string) (*orders.Order, error) {
	orderSchema, ok := r.orders[id]
	if !ok {
		return nil, orders.ErrOrderNotFound
	}

	order := orders.New(id, orderSchema.CustomerName)
	for _, itemSchema := range orderSchema.Items {
		err := order.AddItem(itemSchema.Name, itemSchema.Quantity, itemSchema.UnitPrice)
		if err != nil {
			return nil, err
		}
	}

	return order, nil
}
```

到此為止，我們就有一個非常常見的 API 雛形，在 MVC 框架的架構下也不難做出這樣的實作。因此，下一篇開始我們要開始逐步讓這個功能稍微複雜一些，來了解 Clean Architecture 對於較大的系統能提供的彈性以及擴充性。

