Service / Helper 不是一開始設計出來的,是長出來的。
當你第一次寫某段邏輯,inline 在 Controller 裡。第二次寫類似的,copy-paste。第三次寫,發現複製麻煩——這時才抽 Service。不要更早——三次以下的重複不一定值得抽,過早抽出的抽象往往跟最終形狀不合。
這篇拆解一個真實的 Laravel migration 中 4 個 Service 的長出順序:PermissionMenu (L323) → BackupInfo (L325) → FormDataLoader (L348) → VitalSignsLoader (L473-475)。每個都有明確的「收斂前」狀態(多個 Controller 重複的 inline 邏輯)和「收斂後」狀態(一行 service call),milestone log 記錄了具體收斂多少行重複。
TL;DR
| Service | Milestone | 抽取觸發 | 收斂量 |
|---|---|---|---|
PermissionMenu | L323 | 19 個 Controller 都在重複 4-table JOIN | ~40 行/Controller |
BackupInfo | L325 | 9 個 formlist 重複 7z 路徑 glob | ~18 行/Controller |
FormDataLoader | L348 | 每張 form 開頭 50 行 patient/Q-fields 重複 | ~50 行/form |
VitalSignsLoader | L473-L475 | 4 個 vital-signs Controller 共用樣板 | ~120 行(personRangeMap) |
關鍵設計:抽取後仍須有 Guardrail Test 防止回退(L350)。Service 沒有強制力時,下個 Controller 還是會 inline 寫一次。
背景:54 個 Controller 的 formlist 都長一樣
Phase B 完成後,產出了 103 個 Controller。但 review 程式碼會發現:很多 Controller 的某些方法幾乎 byte-for-byte 一模一樣,只有幾個常數不同。
最明顯的是 formlist 方法。每個模組(mod_care_a / management / employer / …)都有自己的 formlist,做的事都是:
- 查
permission_subcate×permission_item×user_permission2×org_permission4-table JOIN - 查
formgroup取連續性表單 - 查
formnamealias做組織別名覆寫 - 渲染權限圖示選單
差別只在 cateID(每個模組不同)和幾個 boolean knobs。
inline 寫 19 次——這就是 L323 之前的狀態。
Service #1:PermissionMenu(L323)— 第一個抽
起點:mod_care_a 是第一個 call point
L323 milestone 的記錄:
L323 PermissionMenu service + mod_care_a formlist(2026-04-22):新增
App\Services\AppSystemForm\PermissionMenuservice 把module_a/module/*/formlist.php重複的 permission_subcate + permission_item + user_permission2 + org_permission 四表 JOIN + 可選 formnamealias 集中成一個PermissionMenu::fetch($cateID, $mcareID, $nOrgID, $options),options 支援 requireUserOrgID / orderByCatename / selectExtra 等變體參數。mod_care_a 為第一個 call point(cateID=37、PermissionMenu::fetch('37', ...)),formlist.blade.php 從 scaffold 改為實際渲染。剩餘 scaffold-only 模組的 formlist 大多可直接用此 service,下一步批次 port group*family / schedule / mod_admin_b / mod_admin_e / mod_admin_d / mod_assess_a / mod_rehab_b / mod_service_a / consump / mod_special_a / mod_group_admin 等。
從這個 milestone 看出 service 抽取的兩個重要訊號:
- 抽取時點:mod_care_a 是第一個用,但 milestone 說「剩餘 scaffold-only 模組的 formlist 大多可直接用此 service」——意味著作者已經預見到 N 個 call site
- API 設計初版:
fetch($cateID, $mcareID, $nOrgID, $options)——必填三個參數 + options array
初版 API
class PermissionMenu
{
public static function fetch(
$cateID,
string $mcareID,
string $nOrgID,
array $options = []
): Collection;
}
$options 一開始支援三個 knobs:
$requireUserOrgID = $options['requireUserOrgID'] ?? true;
$orderByCatename = $options['orderByCatename'] ?? false;
$selectExtra = $options['selectExtra'] ?? [];
Options 演化(從 3 個到 6 個)
每個新 call site 揭露一個新變體:
| Milestone | 加入的 option | 觸發模組 |
|---|---|---|
| L323 (初版) | requireUserOrgID / orderByCatename / selectExtra | mod_care_a |
| L326 | withOrgPermissionJoin | mod_group_admin(Legacy 沒 JOIN org_permission) |
| L329 | subcateIDs | mod_postnatal_a(額外 IN-filter) |
L326 milestone:
L326 4 個 scaffold-only 模組 formlist 批次移植(2026-04-22):4 個模組 formlist 批次改用
PermissionMenu::fetch()——分別處理 cateID 24({LINK}=” 對應 legacy 未定義的 $link)、cateID 71(前綴主責列表按鈕)、cateID 62/8(依 module flag 分 branch)、cateID 23(用新 optionwithOrgPermissionJoin=false處理 legacy 沒 joinorg_permission、selectExtra=['b.alias']帶 alias 欄位、PermissionMenu::fetchAliases別名覆寫、blade 對 alias==“print” 加target=_blank)。PermissionMenu service 新增withOrgPermissionJoinoption。
第 4 個模組(admin 類)是個 corner case——它的 Legacy formlist 沒有 JOIN org_permission。直接用 PermissionMenu::fetch() 會多一個 JOIN、結果不同。新增 option 而不是改邏輯——保持其他 18 個 call site 不受影響。
衍生方法(不只 fetch)
PermissionMenu 不只一個方法。隨著用例增長,加了幾個衍生:
class PermissionMenu
{
public static function fetch(...): Collection;
public static function fetchAliases(...): array; // formnamealias 查詢
public static function continuityGroups($group): array; // L327 加
public static function continuityGroupsByUserGroup($userGroup): array; // L342 加
}
L327 加 continuityGroups():
L327 PermissionMenu::continuityGroups + 第一個用戶 controller(2026-04-22):
PermissionMenu::continuityGroups(string $group = '2')方法把 19 個 legacy formlist 都有的「連續性填寫表單」區段集中處理——查formgroup WHERE group='2'、跨 DB JOINshared.formorderremind、回傳可被 blade iterate 的 array。涵蓋的模組分布在 ServiceTypeA、ServiceTypeB 等核心業務領域。
19 個 Controller 共用 = 抽出 service 高 ROI。
L342 加 continuityGroupsByUserGroup():
L342 PermissionMenu::continuityGroupsByUserGroup + 兩個 social 類 controller 整理(2026-04-22):新增
continuityGroupsByUserGroup(string $userGroup)方法處理 alternate continuity schema(formgrouporder + formgroup + formremind,純 tenant DB、無跨 DBformorderremind),回傳[{'name','formLink'}]給兩個 social 類 controller(對應 legacy 各自 userGroup=‘2’ / ‘5’)。兩個 controller 中 ~20 行 inline 連續性查詢合併進 service。
continuityGroups 用一個 schema、continuityGroupsByUserGroup 用另一個 schema。沒強迫合併——兩個 method、清楚分工。
最終 PermissionMenu 形狀
namespace App\Services\AppSystemForm;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* L323 — Shared query for module_a formlist permission icon menus.
*
* Most legacy module_a/module/{mod}/formlist.php files repeat the same
* 4-way JOIN against permission_subcate / permission_item /
* user_permission2 / org_permission with only cateID (and a few knobs)
* varying. This service centralizes that query plus the optional
* per-org formnamealias lookup so each module controller shrinks from
* ~40 lines of raw DB code to one `PermissionMenu::fetch(...)` call.
*/
class PermissionMenu
{
public static function fetch($cateID, string $mcareID, string $nOrgID, array $options = []): Collection
{
if ($mcareID === '' || $nOrgID === '') {
return new Collection();
}
$requireUserOrgID = $options['requireUserOrgID'] ?? true;
$withOrgPermissionJoin = $options['withOrgPermissionJoin'] ?? true;
$orderByCatename = $options['orderByCatename'] ?? false;
$selectExtra = $options['selectExtra'] ?? [];
$subcateIDs = $options['subcateIDs'] ?? null;
$query = DB::connection('shared')->table('permission_subcate as a')
->join('permission_item as b', function ($join) use ($cateID, $subcateIDs) {
$join->on('a.subcateID', '=', 'b.subcateID')
->where('a.cateID', '=', (string) $cateID);
if ($subcateIDs !== null) {
$join->whereIn('a.subcateID', $subcateIDs);
}
})
->join('user_permission2 as c', 'c.serNo', '=', 'b.serNo')
->where('c.userID', $mcareID)
->where('c.level', '1');
if ($withOrgPermissionJoin) {
$query->join('org_permission as d', 'b.serNo', '=', 'd.serNo')
->where('d.OrgID', $nOrgID)
->where('d.status', '1');
}
// ... 後續排序 / select / 回傳
}
}
178 行 PHP,包含 4 個 method。從 L323 起接管 19+ 個 Controller 的 formlist。
Service #2:BackupInfo(L325)— 看到第三次寫就抽
抽取觸發
module_a 系統有個特殊功能:管理員可以下載資料庫的 7z 備份檔。檔案路徑用三重 md5 混淆:
$directory = '../' . md5(md5(md5('SQL')))
. '/' . $prefix . 'a' . md5(md5(md5($dbno))) . '/';
$files = glob($directory . '*.7z');
$link = $files[count($files) - 1]; // 最新一份
$filename = str_replace('.7z', '', basename($link));
// ... 接到 formlist 的 {LINK} placeholder
這 18 行邏輯散在 9 個 formlist:
mod_admin_b / mod_admin_d / mod_admin_e /
mod_admin_a / mod_admin_c / mod_admin_g /
evaluation / report / mod_nutri_b
L325 抽取
L325 milestone:
L325 management 三兄弟 formlist 批次移植 + BackupInfo service(2026-04-22):新增
App\Services\AppSystemForm\BackupInfo把 9 個 formlist 共用的備份 7z 檔路徑 glob 邏輯(md5(md5(md5('SQL')))+ DB prefix)集中成BackupInfo::latest($dbno),回傳['link','filename','backupdate']。
namespace App\Services\AppSystemForm;
class BackupInfo
{
/**
* Return the latest backup .7z path + its formatted date.
*
* @return array{link: string, filename: string, backupdate: string}
*/
public static function latest(string $dbno, string $basePath = '..'): array
{
$parts = explode('-', $dbno);
$tenantSuffix = $parts[1] ?? '';
$prefix = strlen($tenantSuffix) === 1 ? '0' . $tenantSuffix : $tenantSuffix;
$directory = $basePath
. '/' . md5(md5(md5('SQL')))
. '/' . $prefix . 'a' . md5(md5(md5($dbno))) . '/';
$files = @glob($directory . '*.7z') ?: [];
if ($files === []) {
return ['link' => '', 'filename' => '', 'backupdate' => ''];
}
$link = $files[count($files) - 1];
$filename = str_replace('.7z', '', basename($link));
return [
'link' => $link,
'filename' => $filename,
'backupdate' => FormHelper::formatDate($filename),
];
}
}
50 行 PHP。為什麼這麼短?
- API 極簡:一個 method、三個欄位回傳值
- 沒有 options——9 個 call site 都用一樣的邏輯,不需要 knobs
- 註解保留 Legacy md5 路徑 scheme:「Intentionally preserves the legacy md5 path scheme — it’s not a secret (fs-only obfuscation) and the caller doesn’t need to know about it.」
Service 抽取的 ROI 思考
PermissionMenu 抽出來省 ~40 行 × 19 個 Controller = ~760 行重複。 BackupInfo 抽出來省 ~18 行 × 9 個 Controller = ~162 行重複。
Service 的價值不只是「省幾行」,是**「強制統一」**。9 個 formlist 各自寫,總有人會寫錯(例如 prefix 忘記補 0)。集中後不會有這個問題。
Service #3:FormDataLoader(L348)— 抽 form-level primitives
抽取觸發
到了 L348(mod_rehab_main 模組已經畢業),開始發現:每張 form 開頭 50 行都一樣。
每張 legacy form 起始邏輯:
// 1. 抓 patient
$patient = DB::connection('mysql')->table('patient')->where('HospNo', $hospNo)->first();
// 2. 抓 inpatientinfo
$inpatient = DB::connection('mysql')->table('inpatientinfo')->where('patientID', $patient->patientID)->first();
// 3. 抓 patient photo(local / S3 切換)
$photo = ...;
// 4. 抓 form table 的 latest row 或指定日期 row
$row = DB::connection('mysql')->table($formTable)->where(...)->first();
// 5. Qxxx_N 欄位展開(checkbox bits → array)
foreach ($row as $key => $value) {
if (preg_match('/^Q(.+)_(\d+)$/', $key, $matches)) {
// ... 解析成 multi-select array
}
}
這 5 步、~50 行邏輯,每張 form 開頭都重複。
L348 抽取
L348 milestone:
L348 FormDataLoader service(2026-04-22):第 4 個
App\Services\AppSystemFormservice——FormDataLoader集中 form-level 資料載入原語(patient($pid)/inpatientInfo($pid)/patientPhoto($hospNo, $nOrgID)/formRow($table, $hospNo, $date)/expandQFields($row, $qOnly))。這些 primitives 是每張 legacy form 檔開頭固定重複的 50 行(patient + Qxxx_N 展開 + form table latest/by-date + photo 路徑)。ModuleRehabController 為第一個 call site:loadFormContext/loadForm14Data/loadForm1Data中 patient / inpatientinfo / pat_idphoto / form-row / expandRowFields 邏輯改 call service;刪除 privateexpandRowFields()helper(被FormDataLoader::expandQFields()取代)。後續 form 實際移植可直接用 FormDataLoader 把每個 form 起始 data-loading 從 ~50 行縮成 5-10 行*。
設計選擇:5 個 primitive method,不是一個 high-level method。
class FormDataLoader
{
public static function patient(string $pid);
public static function inpatientInfo(string $patientID);
public static function patientPhoto(string $hospNo, string $nOrgID);
public static function formRow(string $table, string $hospNo, string $date = '');
public static function expandQFields(array $row, bool $qOnly = false): array;
}
為什麼不寫成 FormDataLoader::loadForm1Context($pid, $date)?
因為每張 form 需要的 primitive 組合不同:
- form1 需要 patient + inpatient + photo + row + Q-expand
- form14 只需要 patient + row(簡單表單)
- form90 需要 patient + inpatient + 多張 form table 一起 expand
如果寫成 high-level method,每張 form 一個變體,反而不如直接組合 primitives。
衍生 helper
L348 之後 FormDataLoader 持續長高層 helper,但這些都是用 5 個 primitive 組出來的:
L471-L473: patientHeader ← patient + photo + 一些業務邏輯
L476: diagnosisMessage ← Qdiag 1..6 合併顯示
L477: patientStatus ← leave/hosp 狀態判定
L477 milestone:
L477 FormDataLoader::patientStatus() + patientStatusHtml() helpers(leave/hosp 狀態判定移到 service;ModuleIOController 私有 method 改 thin wrapper)
「move to service / wrapper unchanged」——這是 service 抽取的常見演化。Controller 私有 method 變成 thin wrapper 呼叫 service,保留向後相容、實作搬到 service。
Service #4:VitalSignsLoader(L473-L475)— 抽特定領域樣板
抽取觸發
到了 vital signs(生命徵象)相關的 4 個 Controller:
ModuleDailyAControllerModuleDailyBControllerModuleIOControllerModuleDailyCController
它們的 form 都需要:
- 載入
vitalsign_person_range(每個個案的生理數值上下限) - 解析 POST 來的
Qdate/Qtime/Qtime2組合成measureTime/recordedTime - 載入 patient header(已經由 FormDataLoader 處理)
L473-L475 三步抽取
L473:
L473 ModuleDailyAController + ModuleDailyBController + ModuleIOController 全面改用 FormDataLoader::patientHeader()(10 處 patient header 載入合併;省去 ~120 行重複樣板)
L474:
L474 VitalSignsLoader::personRangeMap() helper + 4 controller 收斂(vitalsign_person_range 表載入合併到 service;省去 ~80 行重複樣板)
L475:
L475 VitalSignsLoader::resolveMeasureTime() helper + 4 resplist2db save handler 收斂(POST Qdate/Qtime/Qtime2 → measureTime/recordedTime 解析合併到 service;省去 ~120 行重複樣板)
三個 helper 各有量化:
| Helper | 收斂 |
|---|---|
patientHeader (FormDataLoader) | 4 個 controller × 10 處 = ~120 行 |
personRangeMap (VitalSignsLoader) | 4 個 controller = ~80 行 |
resolveMeasureTime (VitalSignsLoader) | 4 個 save handler = ~120 行 |
diagnosisMessage (FormDataLoader, L476) | 4 site = ~60 行 |
總共收斂 ~380 行重複樣板——這還只是 vital-signs / diagnosis 相關的部分。
抽取的演化模式
從 4 個 Service 的故事可以看出 pattern:
階段 1:Inline 寫
└─ 第 1-2 個 Controller 內 inline 寫
└─ 還沒重複到要抽
階段 2:發現第 3 個也要寫
└─ Copy-paste 麻煩
└─ 抽 Service(最小 API)
階段 3:第 4-N 個 Controller 收斂
└─ 每個新 call site 揭露一個新變體
└─ 加 option 到 Service
└─ 不要急著合併不同 schema(用不同 method)
階段 4:Guardrail Test 禁止再 inline(L350)
└─ 抽 Service 後立刻寫 guardrail
└─ 防止下個 Controller 又 inline 寫一次
階段 5:Service 自身演化
└─ 加衍生 method(continuityGroups、patientHeader)
└─ Service 變專業領域知識的 single source of truth
關鍵:階段 2 的時點要抓對。
太早(看到 2 個重複就抽)→ 抽象跟最終形狀不合,後面要重構 Service 自己。 太晚(10 個 Controller 都 inline 寫過)→ 收斂時要同時改 10 個 call site,PR 太大。
3-5 個重複 + 預見到 N 個 call site = 抽取的甜蜜點。
給 Claude Code 的價值
1. Pattern recognition 是 AI 強項
「找出看起來不同但邏輯相同的 4-table JOIN」——這是 AI 比人類快的事。
當你跟 Claude Code 說「review 這 19 個 formlist 找出共通點」,它會看到:
- 都查
permission_subcate - 都 JOIN 同樣 4 張表
- 只有
cateID不同
直接抽 Service 的能力——這個任務交給 AI 比人讀 19 個檔有效率得多。
2. Service Extraction 是 AI 友善的重構
Service 抽取的規則明確:
- 找重複
- 抽出共通部分
- 變數的差異變成 parameter
- 行為的差異變成 option
規則明確的重構,AI 做得比人好。AI 不會疲勞、不會跳過某個 call site、不會手滑 typo。
3. Guardrail 確保 AI 不會繞過 Service
L350 的 AppSystemFormServiceConventionTest:當 Claude Code 嘗試在新 Controller 裡 inline 寫 permission_subcate JOIN,CI 直接失敗。
它讀錯誤訊息「must use App\Services\AppSystemForm\PermissionMenu::fetch() instead」——AI 自己會修正方向。
不需要你每次 review 都提醒「用 service 啦」。Test 替你說了。
給其他人的借鑑
Step 1:等 3 次再抽
第 1 次寫,inline。第 2 次寫,先 copy-paste。第 3 次寫,看一下三份的差異,決定 API。
抽得太早 = 抽錯。
Step 2:先抽 primitive,再抽 high-level helper
FormDataLoader 先抽 5 個 primitive(patient / inpatient / photo / formRow / expandQFields),之後再加 patientHeader / patientStatus。
如果反過來——先寫 patientHeader,發現需要 inpatient → 再回去拆 primitive——重構 service 自己會很痛。
Step 3:Option 而不是分支邏輯
L326 加 withOrgPermissionJoin option 而不是寫 fetchWithoutOrgJoin() 分支 method。
每個 option 對應一個 corner case。Method 多會分散 documentation;options 集中在一個 method、一個 documentation。
Step 4:不同 schema 用不同 method
continuityGroups() 和 continuityGroupsByUserGroup() schema 不同——L342 milestone 沒強迫合併。
兩個 method 比一個 method + 大量分支邏輯清楚。
Step 5:抽完立刻寫 Guardrail
L350 的 AppSystemFormServiceConventionTest 在 Service 抽出後馬上引入。
「等等再寫 guardrail」= 不會寫。抽 Service 跟寫 Guardrail 是同一個 PR。
結語
Service 抽取的價值不只是「省幾行 code」——是**「把領域知識集中」**。
permission_subcate × permission_item × user_permission2 × org_permission 4-table JOIN 的細節(哪些 column 要 select、哪些 where 條件、order by 順序)原本散在 19 個 Controller。每改一次邏輯,要同時改 19 個地方,永遠改不齊。
抽到 PermissionMenu 後,這個知識在一個地方。新 Controller 不用懂 JOIN 怎麼寫,懂 PermissionMenu::fetch() 怎麼呼叫就好。
這個轉換很重要——從「重複的程式碼」變成「集中的知識」。前者是技術債,後者是資產。
如果你的專案有「同一段邏輯散在 N 個 Controller」的情況,先看看是不是 N >= 3。是的話,抽 Service 的時候到了。記得:抽完立刻寫 Guardrail Test,不然下個 Controller 又會 inline 寫一次。