Moriwise
森睿科技

Solo founder 的 monorepo — 4 個 app + 5 個 package 的設計取捨

Moriwise Team7 min

為什麼一個人的公司需要 monorepo

Solo founder 通常被建議走相反方向:一個產品、一個 repo、一個 deploy target。少維護、少複雜度、少踩坑。

但 Moriwise 從第一天就是 monorepo。理由不是「我以後會擴大團隊」這種憧憬,而是當下的實際痛點:

  • 4 個產品共用同一套 LINE OA / ECPay / Resend 整合
  • 同一個 brand identity 想跨產品保持一致
  • 一個人沒辦法在 4 個 repo 之間切上下文還記得每個的版本

換句話說,monorepo 不是規模問題,是上下文成本問題

現況快照

4
Apps
amibon / web / crm-ins / bot
6
Packages
ui / article-kit / line / ecpay / email / notion-cms
3
Vercel projects
+ 1 pm2 (WSL2)
text
Moriwise/
├── apps/
│ ├── amibon/ ← SaaS 任務派遣(LIVE,2167 tests)
│ ├── web/ ← 公司官網 + 文章系統
│ ├── amibon_crm_ins/ ← 保險業 CRM(LIVE,B2B sales-ready)
│ └── discord-bot/ ← 內部 DevOps platform
└── packages/
├── ui/ ← shadcn-based 共用元件
├── article-kit/ ← 文章排版系統(Phase 2 新增)
├── notion-cms/ ← Notion API client
├── line/ ← LINE Messaging 包裝
├── ecpay/ ← ECPay 金流
└── email/ ← Resend client

4 個 app 走 npm workspaces,部署到 3 個 Vercel project + 1 個 pm2 (WSL2)。

第一個取捨:npm workspaces vs pnpm vs Turborepo

很多 monorepo 教學會推 pnpm + Turborepo + Nx 的全家桶。我們刻意不用。

選擇 npm workspaces 的原因,是工具的可解釋性比效能重要:

  • 一個人 debug 時,不想跟 pnpm 的 symlink 結構打架
  • 不想跟 Turborepo 的 cache invalidation 戰鬥
  • Vercel 對 npm workspaces 支援相對直白
  • Phase 2 才會評估切 pnpm(地基都還沒蓋穩)

第二個取捨:app 分還是合?

apps/amibonapps/amibon_crm_ins 都是租戶系統、都用 LINE 推播、都接 ECPay。要不要合成一個 app?

我們故意分開。三個原因:

  1. 業務週期不同步。Amibon 在 closed beta,CRM 已經是 B2B sales-ready。同個 deploy 滾動會互相牽扯
  2. 資安隔離。保險業客戶的 PII 不能跟一般 SaaS 用戶共用 session middleware
  3. 故障爆炸範圍。app A 推壞,不該讓 app B 也下線

不分開的部分是 packages/。LINE 怎麼推、ECPay 怎麼簽,那邊的邏輯只能有一份。

第三個取捨:packages 怎麼切

最早的 packages/line 包了所有 LINE 操作 — flex message builder、reply、push、webhook 驗證。看起來合理,直到 Vercel build fail。

問題出在 --workspaces=false:Vercel install consumer app 的時候,不會去裝 packages/line 自己的 deps。如果 packages/line import 了 @line/bot-sdk,build 階段就找不到型別。

修法是讓每個 package 對「自己的 node_modules 是空的」這件事 robust

  • 第三方型別重新匯出時,type-only import + re-export
  • 不要 import 其他 workspace package(會打開遞迴依賴)
  • 視覺元件 inline 化,不依賴 packages/ui

對比:理想化的設計 vs 我們實際的取捨

對照組是「教科書 monorepo」。我們的實際選擇刻意不漂亮:

教科書 monorepo

看起來很對,實際上養不起

  • pnpm + Turborepo + Nx 全家桶
  • 每個 package 都有自己的 README + CHANGELOG
  • 所有 app 共用同一個 design system 一致到像素
  • CI matrix 跑全 package 全 app build
  • 嚴格 semver,內部 package 也照升

我們的取捨

刻意省事,留 debug 預算給真問題

  • 純 npm workspaces,Vercel 直白支援
  • package README 有就好,CHANGELOG 等真有 consumer 再寫
  • Amibon 跟 Moriwise 的 design system 故意分開
  • Vercel ignoreCommand 只 build 改動到的 app
  • 內部 package 用 file: link,不走 semver

Vercel 的 ignoreCommand 也是同個概念

每個 app 有自己的 vercel.json,宣告它依賴哪些 packages:

bash
git fetch --depth=500 origin $VERCEL_GIT_PREVIOUS_SHA 2>/dev/null || exit 1
git diff --quiet $VERCEL_GIT_PREVIOUS_SHA HEAD -- \
apps/<name>/ <relevant packages> || exit 1

只有 watch path 內的檔案有變才會觸發 build。

效果:docs-only commit 不會觸發 4 個 project 重 build;只改 amibon 不會浪費 web build minute。對 Vercel Pro plan(usage-based billing 2026-04-24+ 上線)這條省下的不是時間,是錢。

一個人的 monorepo 該記住的事

最好的架構是一個人能維護的架構,不是教科書最漂亮的架構。

— Moriwise 工程原則第一條

把這句話打在牆上。每次想引入新工具、切新 package、立新 boundary 之前都默念一次。

接下來

Phase 3 packages/ 第二批整理計畫

  • 把 amibon 跟 CRM 共用的 tenant routing 抽成 packages/tenant-shell
  • 原因不是 DRY — 是兩邊現在的 middleware 已經 drift 出 bug
  • 決策準則:這份重複是不是 bug 的溫床?是 → 抽。不是 → 留著。

每個抽 package 的決策都該回到同一個問題:這份重複是不是 bug 的溫床?是 → 抽。不是 → 留著重複,沒事。