Spec-First API:用 codegen 約束 LLM 與人類的 drift

Spec-First API:用 codegen 約束 LLM 與人類的 drift

前言 事情是這樣開始的。 我請 agent 幫忙加一個 endpoint,它三分鐘寫完,handler 乾淨、error handling 完整、unit test 也跟著補齊,go test ./... 全綠。此時我想到的是,agent 真的有做完嗎?route 真的有被註冊嗎? 換成三年前的我,也會漏,那要怎麼樣讓 Agent 不會漏?這個系統從一開始就沒有任何機制能發現有人漏改。以前它靠的是 reviewer 的眼力與同一批人的肌肉記憶撐著;現在把產出速度乘上十倍,這條防線就直接被沖垮了。這不是 agent 的問題,是架構債被提早引爆。 這篇想記錄的,就是後來把這個服務從 code-first 遷移到 spec-first 的過程:用 OpenAPI 當單一真值來源(SSOT),用 oapi-codegen 產生 typed interface,讓 compiler 與 CI 去做原本靠人記憶的事情。 問題不是「有三份檔案」 遷移前,同一條 API 的定義散落在三個地方: infra route definition ← path / method / authorizer cmd/*/main.go ← mux.HandleFunc 手寫註冊 Go swag comments ← @Router / @Param / @Success 三份檔案本身不是問題,同一個概念散在多個檔案是常態。 真正的問題是,它們彼此不知道對方存在。 改 path,可能只改到 Go route,忘了 infra;改 response shape,可能只改 handler struct,忘了註解;授權設定只活在 infra 層,Go 跟 OpenAPI 都不知道有這回事。而 swaggo 的註解在 repo 裡形同空轉,它是「程式碼的產物」,而我們沒有任何機制拿它回頭驗證程式碼。 ...

2026-08-11 · 10 分鐘 · 1996 字 · map[email:[email protected] name:Raiven Kao]
Pragmatic Clean Architecture in Go

Pragmatic Clean Architecture in Go

前言 在大型系統或需要長期維護的產品中,導入 DDD(Domain-Driven Design)或 Clean Architecture 幾乎是業界的標準答案,分層清晰、職責明確、可測試性高,這些優點毋庸置疑。 但問題是,不是每個專案都是大型系統。 當你面對的是一個內部工具、side project、或是剛起步的新產品,Clean Architecture 的大全套 Use Case、Port、Adapter、Aggregate、Repository Interface 全放 domain,往往會讓你在還沒寫第一行商業邏輯之前,就先在資料夾結構裡迷路了三個小時,或是讓不熟悉的貢獻者花費大量時間在閱讀架構。 這篇文章想討論的是:在不犧牲可測試性與可維護性的前提下,哪些抽象可以捨棄、哪些值得保留,以及我自己踩過的一些坑。 捨棄什麼 獨立的 Port/Adapter 層 Clean Architecture 中,Use Case 透過明確定義的 input/output port 與外界溝通,搭配 Adapter 負責格式轉換。這在大型系統中確實有其價值,但它帶來的代價是:為了讓每一層都能獨立替換,你需要維護大量的介面與轉換邏輯,而這些轉換邏輯往往只是把 A struct 的欄位複製到 B struct。 在小專案中,這條轉換鏈可以大幅縮短。HTTP handler 本身就可以負責 DTO 的轉換,不需要再多一層 Adapter。 DDD 的 Aggregate Aggregate 是 DDD 中保護業務不變式(invariant)的邊界,透過 Aggregate Root 統一管理相關物件的存取。這個概念本身沒有問題,但在小專案中,如果業務規則還不夠複雜到需要嚴格的不變式保護,過早引入 Aggregate 反而會讓簡單的 CRUD 操作變得冗長。 保留什麼 捨棄了部分複雜度之後,剩下的四層架構已經足以應付絕大多數的小專案需求。 package domain 這裡只放 Domain Model 與 Value Object。 值得特別說明 Domain Model 的定義。它不等同於 DDD 裡的 Domain,不是什麼深奧的設計概念,Domain Model 只是在描述「這個應用程式如何跟外部世界互動、邊界在哪」,也就是你的核心資料結構與它們身上的行為。有些「賣課」的人喜歡把 Domain Model 直接等同於 DDD 的 Domain,實際上它是個更基礎、更普遍的概念,可以參考這支影片的解釋。Domain Model 中不需要也不該出現任何技術細節,例如回傳是否是 JSON 等等,這些 Domain Model 甚至要能讓 PM 與非技術人員也「聽的懂」。 ...

Interface 不是有開就好:從一個 PR 來看抽象化的重要性

前言 最近團隊正在開發一個新產品,其中一個核心功能需要 client 與 server 之間進行即時、雙向的溝通。經過一番技術評估,我們決定採用 WebSocket 來實現這個需求。 身為一個良好習慣的開發團隊,我們在開發初期就導入了依賴注入(Dependency Injection),希望透過界面(Interface)來解耦商業邏輯與具體的實作,這樣不僅能提高程式碼的可測試性,未來在更換底層實作時也能更加輕鬆。 一切聽起來都很美好,直到我在一次 Code Review 中,看到了一段熟悉的程式碼。 一個 PR 的故事 在我們的 Domain Layer,也就是處理核心商業邏輯的地方,我看到同事定義了下面這個 interface: // package/to/domain/service.go // WebSocketService defines the interface for websocket communication. type WebSocketService interface { // StartAndLinsten starts the service and listens for incoming messages. StartAndLinsten(ctx context.Context) error // Send sends a message to the client. Send(ctx context.Context, message any) error // ... other methods } 第一眼看過去,好像沒什麼大問題。有名稱、有方法、也確實是個 interface。然而,當我細看 WebSocketService 這個命名時,總覺得哪裡怪怪的。 於是我在 PR 上留下了這樣的 comment: 這個界面主要是抽象化 client 與 server 間的互動,不應該侷限於 WebSocket 這個 Protocol。假如我們未來要換成使用 socket.io 或是 gRPC stream,是不是連 domain 層的 interface 也要跟著改動? ...