四層 DDD:domain 層一行框架都不准碰
我的每個後端模組都是同一個四層結構,而整套架構只有一條不可協商的鐵律:domain/ 是純 TypeScript,零框架依賴。NestJS 與 TypeORM 只准出現在 application 層以下。
code
modules/<module>/
├── domain/ # entities / value-objects / events / ports
├── application/ # services / dtos / listeners
├── infrastructure/ # orm-entity / mapper / repository 實作
└── presentation/ # controllers / guards / schedulers
為什麼這麼執著
因為框架會老,領域邏輯不會。「筆記從種子長成常青」「訂閱者要 double opt-in 才算數」這些規則跟 NestJS 一點關係都沒有——它們寫成純函式與純物件,就能被單元測試以毫秒級驗證,也能在未來搬去任何 runtime(比如 serverless)。
實務上的具體紀律:
- Entity 一律走靜態工廠:
Entity.create(props)生新物件(產 UUID、蓋時間戳)、Entity.hydrate(props)從 DB 重建。禁new——建構的不變量只在工廠裡驗一次。 - Mapper 是唯一的雙面人:
toDomain(orm)/toPersistence(entity)是全 codebase 僅有的同時看得到 ORM 型別與領域型別的地方。ORM entity 絕不進 application 層。 - Repository 以介面(port)存在於 domain:
INoteRepository是領域語言,TypeORM 實作在 infrastructure,用 Symbol token 綁 DI。服務只認識 port。
事件是一等公民
Entity 的狀態變更用 addDomainEvent() 收集事件,儲存後由 dispatcher 派發。這個看似小的設計,讓後來的「一個事件、三個出口」(站內通知/SSE/webhook)成為可能——producer 完全不認識 consumer。
代價與回報
四層的代價是檔案多:一個模組動輒十幾個檔案,寫 CRUD 時你會想罵人(所以我寫了產生器一鍵鋪骨架)。回報是每一層都可以獨立演化:跨模組交易加進來的時候,domain 的 port 一個字都沒改——這就是分層真正的意義。
讀到這裡若有一點收穫,替這顆種子澆一次水——匿名、一人一次。