
Place Order 實作 Controller 部分 - Clean Architecture in Go
我們已經透過 oapi-codegen 產生了 Controller 的介面定義,然而還沒有任何實作。在 Clean Architecture 中 Controller 要扮演 Adapter(轉接器)的角色,因此我們需要透過 Controller 把使用者的輸入轉換成 UseCase 可以使用的格式。
定義 UseCase
要處理 Controller 的實作,我們需要先確定 Place Order 所需要的資料和回傳為何,因此我們會需要先定義 UseCase 的介面。
1// internal/usecase/place_order.go
2
3type PlaceOrderItem struct {
4 Name string
5 Quantity int
6 UnitPrice int
7}
8
9type PlaceOrderInput struct {
10 Name string
11 Items []PlaceOrderItem
12}
13
14type PlaceOrderOutput struct {
15 Id string
16 Name string
17 Items []PlaceOrderItem
18}
19
20type PlaceOrder struct {
21}
22
23func NewPlaceOrder() *PlaceOrder {
24 return &PlaceOrder{}
25}
26
27func (u *PlaceOrder) Execute(ctx context.Context, input *PlaceOrderInput) (*PlaceOrderOutput, error) {
28 return &PlaceOrderOutput{
29 Id: uuid.NewString(),
30 Name: input.Name,
31 Items: input.Items,
32 }, nil
33}
上述這段程式碼看起來會跟 Controller 幾乎相同,在 Controller 我們可以透過 oapi-codegen 根據 OpenAPI 文件產生,這邊卻是要手動撰寫,看起來似乎是「重工」的情況。
然而,Controller 會因為不同的框架、格式有不一樣的實作,在 UseCase 則會是相同的,假設未來我們需要將 Order Service 切割成一個獨立的 Microservice(維服務)才能直接把 UseCase 搬移,而不需要從 Controller 抽離程式碼。
因為這樣的原因,我們才得以實踐 Clean Architecture 期望處理的情境,能夠輕鬆的替換底層的框架,甚至在其他地方重新實現。
實作 Controller
我們已經確定 UseCase 該有怎樣的介面後,就可以在 Controller 上把這個介面串接上去,並且將使用者的輸入轉換成適合的格式。
首先,我們會利用 wire 來幫助我們依賴注入,之前特意設計了 Api
這個結構就可以被我們用來描述會被注入的 UseCase 有哪些。
1// internal/api/rest/rest.go
2
3var _ ServerInterface = &Api{}
4
5type Api struct {
6 PlaceOrderUsecase *usecase.PlaceOrder
7}
接著完成 Controller 的實作,我們就可以通過以下的測試。
1Feature: Order
2 Scenario: I can place an order
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": "Banana",
15 "quantity": 3,
16 "unit_price": 5
17 }
18 ]
19 }
20 """
21 Then the response status code should be 200
22 And the response JSON contains "id" string
23 And the response JSON contains "name" with value "Aotoki"
24 And the response JSON contains "items[0].name" with value "Apple"
25 And the response JSON contains "items[0].quantity" with value 2
26 And the response JSON contains "items[0].unit_price" with value 10
27 And the response JSON contains "items[1].name" with value "Banana"
28 And the response JSON contains "items[1].quantity" with value 3
29 And the response JSON contains "items[1].unit_price" with value 5
1// internal/api/rest/place_order.go
2
3package rest
4
5import (
6 "encoding/json"
7 "net/http"
8
9 "github.com/elct9620/clean-architecture-in-go-2025/internal/usecase"
10)
11
12func (api *Api) PlaceOrder(w http.ResponseWriter, r *http.Request) {
13 var req PlaceOrderRequest
14 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
15 http.Error(w, err.Error(), http.StatusBadRequest)
16 return
17 }
18 input := buildPlaceOrderInput(req)
19
20 out, err := api.PlaceOrderUsecase.Execute(r.Context(), input)
21 if err != nil {
22 http.Error(w, err.Error(), http.StatusBadRequest)
23 return
24 }
25
26 res := buildPlaceOrderResponse(out)
27 if err := json.NewEncoder(w).Encode(res); err != nil {
28 http.Error(w, err.Error(), http.StatusInternalServerError)
29 return
30 }
31}
32
33func buildPlaceOrderInput(req PlaceOrderRequest) *usecase.PlaceOrderInput {
34 inItems := make([]usecase.PlaceOrderItem, 0, len(req.Items))
35 for _, item := range req.Items {
36 inItems = append(inItems, usecase.PlaceOrderItem{
37 Name: item.Name,
38 Quantity: int(item.Quantity),
39 UnitPrice: int(item.UnitPrice),
40 })
41 }
42
43 return &usecase.PlaceOrderInput{
44 Name: req.Name,
45 Items: inItems,
46 }
47}
48
49func buildPlaceOrderResponse(out *usecase.PlaceOrderOutput) *PlaceOrderResponse {
50 outItems := make([]OrderItem, 0, len(out.Items))
51 for _, item := range out.Items {
52 outItems = append(outItems, OrderItem{
53 Name: item.Name,
54 Quantity: float32(item.Quantity),
55 UnitPrice: float32(item.UnitPrice),
56 })
57 }
58
59 return &PlaceOrderResponse{
60 Id: out.Id,
61 Name: out.Name,
62 Items: outItems,
63 }
64}
從經驗上來看,上面的實作似乎是沒必要的消耗,然而這剛好就是 Controller 最主要的功能之一,一個是驗證輸入的資料格式,另一個則是將這些格式轉換成 UseCase 可以使用的資料。
舉例來說,在 JSON 中採用了 float32
的型別,然而在我們的設計 Quantity
和 UnitPrice
都是 int
型別,那麼就需要有人處理,只要在 Controller 都處理完善後,後續的流程就不需要再擔心會發生預期外的情況。
必要性
我們在學習後端相關知識的時候,大多會從採用 MVC 的框架開始學起,因此會對於上述這樣的做法感到疑惑,覺得「有必要嗎?」
根據這幾年實踐的經驗來看,假設整個系統非常單純的時候,是可以不這樣做的。畢竟我們很少遇到需要同時支援 RESTful API 和 gRPC 的情境,然而當我們開始有 APIv1 和 APIv2 這樣的差異時,到是一個不錯的時機點進行重構。
舉例來說,假設 APIv1 的時候,我們預期回傳只提供 id
欄位。
1{
2 "id": "a621f661-e620-4926-b0f4-c726096c93b9"
3}
然而到了 APIv2 為了讓前端可以更容易處理,我們希望將 Name 和 Order Items 也都呈現出來,因此要改為下面的樣子。
1{
2 "id": "a621f661-e620-4926-b0f4-c726096c93b9",
3 "name": "Aotoki",
4 "items": [
5 {
6 "name": "Apple",
7 "quantity": 2,
8 "unit_price": 10
9 },
10 {
11 "name": "Banana",
12 "quantity": 3,
13 "unit_price": 5
14 }
15 ]
16 }
這個時候如果我們在處理建立訂單的邏輯是跟 Controller 綁定的,那麼就會出現需要複製相似的程式碼到 APIv2 的 Controller 中,然而有 UseCase 的狀況下,只是 buildPlaceOrderResponse
這個 Helper 的實作差異。
同樣的情境也可以套用到未來如果我們在 APIv2 想要改由從 API Token 取得下單者名稱,所以要拿掉 name
欄位的輸入,實際上背後的處理還是會用到,因此我們只需要在 Controller 階段做一些處理,就可以讓這些機制順利地被實現。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 連載介紹 - Clean Architecture in Go
- 目標設定 - Clean Architecture in Go
- wire 的依賴注入 - Clean Architecture in Go
- 案例說明 - Clean Architecture in Go
- 操作介面設計 - Clean Architecture in Go
- Place Order 實作 Controller 部分 - Clean Architecture in Go
- Place Order 實作 Entity 部分 - Clean Architecture in Go
- Place Order 實作 Repository 部分 - Clean Architecture in Go