從 inline 到 Service:4 個 Laravel Service 的長出方式


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

ServiceMilestone抽取觸發收斂量
PermissionMenuL32319 個 Controller 都在重複 4-table JOIN~40 行/Controller
BackupInfoL3259 個 formlist 重複 7z 路徑 glob~18 行/Controller
FormDataLoaderL348每張 form 開頭 50 行 patient/Q-fields 重複~50 行/form
VitalSignsLoaderL473-L4754 個 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,做的事都是:

  1. permission_subcate × permission_item × user_permission2 × org_permission 4-table JOIN
  2. formgroup 取連續性表單
  3. formnamealias 做組織別名覆寫
  4. 渲染權限圖示選單

差別只在 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\PermissionMenu service 把 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 抽取的兩個重要訊號:

  1. 抽取時點:mod_care_a 是第一個用,但 milestone 說「剩餘 scaffold-only 模組的 formlist 大多可直接用此 service」——意味著作者已經預見到 N 個 call site
  2. 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 / selectExtramod_care_a
L326withOrgPermissionJoinmod_group_admin(Legacy 沒 JOIN org_permission)
L329subcateIDsmod_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(用新 option withOrgPermissionJoin=false 處理 legacy 沒 join org_permissionselectExtra=['b.alias'] 帶 alias 欄位、PermissionMenu::fetchAliases 別名覆寫、blade 對 alias==“print” 加 target=_blank)。PermissionMenu service 新增 withOrgPermissionJoin option

第 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 JOIN shared.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、無跨 DB formorderremind),回傳 [{'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\AppSystemForm service——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;刪除 private expandRowFields() 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:

  • ModuleDailyAController
  • ModuleDailyBController
  • ModuleIOController
  • ModuleDailyCController

它們的 form 都需要:

  1. 載入 vitalsign_person_range(每個個案的生理數值上下限)
  2. 解析 POST 來的 Qdate / Qtime / Qtime2 組合成 measureTime / recordedTime
  3. 載入 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 寫一次。