一月底的週末, 剛從菠菜離職的學弟 K君 約我吃了頓飯(看來是年終到手了是吧), 和我分享了這一段時間的生活還有工作近況, 在職場上遇到的鬼故事之類的.
依稀記得當年一起在 SI 公司工作的時候, 遇上需求談不清楚又容易情緒管理失控的 SA, 我們還一起把 teams 的頭像掛上 Darkest Dungeon 裡的 iron crown (大致上就是負面 San 值的 Debuff, 當精神理智降低時會出現).
後來, 他和我分享了一段流程狀態的設計, 我僅看了一眼, 那段程式碼就像鐵皇冠的印記一樣深深烙印在我的眼睛, 把我拖入瘋狂的深淵.
(以下的範例經脫敏設計, 主要為了體現奇異的流程狀態設計, 可能會有一些不符合現實需求的地方)
一個關於醫院代理掛號的服務 K 老弟的需求大概是這樣, 他的系統提供一個 web base 的服務, 協助會員在指定的日期區間中, 對多家醫院進行網路掛號, 會員最後再從掛號成功的醫院中, 選擇一家看病.
而系統的流程則是如下:
用戶輸入基本資料與上傳掛號文件
系統自動檢核文件, 若檢核失敗, 轉人工處理審核
審核通過, 系統啟動 RPA, 到各醫院的系統上進行掛號
若 RPA 失敗, 則轉人工操作掛號
掛號後, 系統會在會員的功能中顯示每個醫院的掛號狀態
會員選擇確切要掛號的醫院, 並取消其餘成功的掛號
恩… 聽起來沒啥問題, 整個需求也很合理方便, 感覺我媽就會想註冊會員, 說他多好多好用, 然後叫我幫忙操作.
而真正的問題還是要撥開橘子才知道…, 這一段邏輯大概由三段程式組成:
Enum 狀態機
Enum 事件與狀態
StateService
1. 巢狀狀態機 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 @Component @Slf4j public class AppointmentFlowManager { public static Long flowTemplateId = 9487L ; public AppointmentStateEnum appointmentSubmit (AppointmentStateEnum currentState, AppointmentEvent event) { switch (currentState) { case PENDING_DOCUMENT_VERIFICATION: switch (event) { case DOC_VERIFICATION_SUBMIT: return AppointmentStateEnum.DOCUMENT_VERIFYING; case DOC_MANUAL_VERIFY_SUBMIT: return AppointmentStateEnum.PENDING_HOSPITAL_RESPONSE; } break ; case DOCUMENT_VERIFYING: switch (event) { case DOC_VERIFICATION_SUCCESS: return PENDING_RPA_APPOINTMENT; case DOC_VERIFICATION_FAILED: return PENDING_MANUAL_VERIFICATION; } break ; case PENDING_MANUAL_VERIFICATION: switch (event) { case MANUAL_VERIFY_APPROVE: return PENDING_RPA_APPOINTMENT; case MANUAL_VERIFY_REJECT: return APPOINTMENT_FAILED; } break ; case PENDING_RPA_APPOINTMENT: switch (event) { case RPA_START_APPOINTMENT: return RPA_PROCESSING; case MANUAL_APPOINTMENT_SUBMIT: return PENDING_HOSPITAL_RESPONSE; } break ; case RPA_PROCESSING: switch (event) { case RPA_SUCCESS: return PENDING_HOSPITAL_RESPONSE; case RPA_FAILED: return RPA_FAILED_PENDING_MANUAL; } break ; case RPA_FAILED_PENDING_MANUAL: switch (event) { case MANUAL_APPOINTMENT_SUBMIT: return PENDING_HOSPITAL_RESPONSE; case RPA_RETRY: return RPA_PROCESSING; case GIVE_UP_APPOINTMENT: return APPOINTMENT_FAILED; } break ; case PENDING_HOSPITAL_RESPONSE: switch (event) { case HOSPITAL_APPROVE: return APPOINTMENT_SUCCESS; case HOSPITAL_REJECT: return APPOINTMENT_FAILED; } break ; case APPOINTMENT_SUCCESS: switch (event) { case MEMBER_CONFIRM_HOSPITAL: return APPOINTMENT_CONFIRMED; case MEMBER_CANCEL_APPOINTMENT: return APPOINTMENT_CANCELLED; } break ; case APPOINTMENT_CONFIRMED: break ; case APPOINTMENT_CANCELLED: break ; case APPOINTMENT_FAILED: break ; } throw new RuntimeException ("This appointment has been updated or submitted." ); } public AppointmentStateEnum getInitialState () { return AppointmentStateEnum.PENDING_DOCUMENT_VERIFICATION; } }
2. 狀態與事件 Enum 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public enum AppointmentStateEnum { PENDING_DOCUMENT_VERIFICATION(1L , "待文件驗證" ), DOCUMENT_VERIFYING(2L , "文件驗證中" ), PENDING_MANUAL_VERIFICATION(3L , "待人工審核" ), PENDING_RPA_APPOINTMENT(4L , "待RPA掛號" ), RPA_PROCESSING(5L , "RPA處理中" ), RPA_FAILED_PENDING_MANUAL(6L , "RPA失敗待人工" ), PENDING_HOSPITAL_RESPONSE(7L , "等待醫院回應" ), APPOINTMENT_SUCCESS(8L , "掛號成功" ), APPOINTMENT_FAILED(9L , "掛號失敗" ), APPOINTMENT_CONFIRMED(10L , "已確認掛號" ), APPOINTMENT_CANCELLED(11L , "已取消掛號" ); private Long code; private String desc; public static AppointmentStateEnum getByCode (Long code) { for (AppointmentStateEnum state : values()) { if (state.getCode().equals(code)) { return state; } } throw new IllegalArgumentException ("Unknown state code: " + code); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public enum AppointmentEvent { DOC_VERIFICATION_SUBMIT, DOC_VERIFICATION_SUCCESS, DOC_VERIFICATION_FAILED, DOC_MANUAL_VERIFY_SUBMIT, MANUAL_VERIFY_APPROVE, MANUAL_VERIFY_REJECT, RPA_START_APPOINTMENT, RPA_SUCCESS, RPA_FAILED, RPA_RETRY, MANUAL_APPOINTMENT_SUBMIT, HOSPITAL_APPROVE, HOSPITAL_REJECT, MEMBER_CONFIRM_HOSPITAL, MEMBER_CANCEL_APPOINTMENT, GIVE_UP_APPOINTMENT; }
1 2 3 4 5 6 7 8 9 10 public enum MemberAppointmentStatusEnum { PENDING_VERIFICATION("待驗證" ), PROCESSING("處理中" ), PENDING_MEMBER_SELECTION("待選擇醫院" ), APPOINTMENT_CONFIRMED("已確認掛號" ), ALL_FAILED("全部失敗" ); private String desc; }
3. StateService 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 @Service @Slf4j public class AppointmentStateService { @Autowired private HospitalAppointmentMapper hospitalAppointmentMapper; @Autowired private AppointmentFlowManager flowManager; @Transactional public void updateAppointmentState (String appointmentNo, AppointmentEvent event) { HospitalAppointment appointment = hospitalAppointmentMapper.selectOne( new QueryWrapper <HospitalAppointment>().eq("appointment_no" , appointmentNo) ); AppointmentStateEnum currentState = AppointmentStateEnum.getByCode(appointment.getState()); AppointmentStateEnum nextState = flowManager.appointmentSubmit(currentState, event); appointment.setState(nextState.getCode()); appointment.setUpdateTime(LocalDateTime.now()); hospitalAppointmentMapper.updateById(appointment); this .handleSpecialLogic(appointment, nextState); this .updateMemberStatus(appointment.getMemberId(), appointment.getAppointmentBatchNo()); } private void handleSpecialLogic (HospitalAppointment appointment, AppointmentStateEnum nextState) { if (nextState.equals(AppointmentStateEnum.APPOINTMENT_CONFIRMED)) { List<HospitalAppointment> otherAppointments = hospitalAppointmentMapper.selectList( new QueryWrapper <HospitalAppointment>() .eq("member_id" , appointment.getMemberId()) .eq("appointment_batch_no" , appointment.getAppointmentBatchNo()) .ne("appointment_no" , appointment.getAppointmentNo()) ); boolean hasConfirmed = otherAppointments.stream() .anyMatch(apt -> apt.getState().equals(AppointmentStateEnum.APPOINTMENT_CONFIRMED.getCode())); if (hasConfirmed) { throw new BizException ("You have already confirmed another hospital appointment." ); } otherAppointments.stream() .filter(apt -> apt.getState().equals(AppointmentStateEnum.APPOINTMENT_SUCCESS.getCode())) .forEach(apt -> { apt.setState(AppointmentStateEnum.APPOINTMENT_CANCELLED.getCode()); apt.setCancelReason("Member confirmed another hospital" ); apt.setCancelTime(LocalDateTime.now()); hospitalAppointmentMapper.updateById(apt); try { hospitalApiService.cancelAppointment(apt.getAppointmentNo()); } catch (Exception e) { log.error("Failed to cancel appointment: {}" , apt.getAppointmentNo(), e); } }); } } private void updateMemberStatus (String memberId, String appointmentBatchNo) { List<HospitalAppointment> appointments = hospitalAppointmentMapper.selectList( new QueryWrapper <HospitalAppointment>() .eq("member_id" , memberId) .eq("appointment_batch_no" , appointmentBatchNo) ); List<Long> stateList = appointments.stream().map(HospitalAppointment::getState).collect(Collectors.toList()); MemberAppointmentStatusEnum memberStatus = this .calculateMemberStatus(stateList); log.info("Member {} batch {} status: {}" , memberId, appointmentBatchNo, memberStatus); } private MemberAppointmentStatusEnum calculateMemberStatus (List<Long> stateList) { if (stateList.stream().allMatch(state -> state.equals(9L ) || state.equals(11L ))) { return MemberAppointmentStatusEnum.ALL_FAILED; } if (stateList.stream().anyMatch(state -> state.equals(10L ))) { return MemberAppointmentStatusEnum.APPOINTMENT_CONFIRMED; } if (stateList.stream().anyMatch(state -> state.equals(8L ))) { return MemberAppointmentStatusEnum.PENDING_MEMBER_SELECTION; } if (stateList.stream().allMatch(state -> state.equals(1L ) || state.equals(2L ) || state.equals(3L ))) { return MemberAppointmentStatusEnum.PENDING_VERIFICATION; } return MemberAppointmentStatusEnum.PROCESSING; } }
剝開橘子 老實說, 基於程式能跑就不要動他的原則, 如果是我自己在工作內看到這些程式, 應該內心會毫無波瀾, 但剛好有機會就來嘗試 redesign 一下, 我們先盤一盤目前有的問題.
1. 巢狀 switch AppointmentFlowManager 裡那坨巢狀 switch, 雖然照著看還是能理解每個接續的步驟, 但每一次更新,就需要連帶調整多個地方.
舉個實際的需求:
文件驗證要多一段 AI 初審,AI 過了就直接走 RPA,AI 沒過才轉人工
我就會需要改:
AppointmentStateEnum:加一個 AI_REVIEWING 狀態
AppointmentEvent:加 AI_REVIEW_SUCCESS、AI_REVIEW_FAILED 兩個事件
AppointmentFlowManager:在 switch 裡加 AI_REVIEWING 的 case,同時改 DOCUMENT_VERIFYING 的 transition 指向
StateService.handleSpecialLogic:如果 AI 審核有特殊邏輯,再補一段 if
calculateMemberStatus:更新判斷條件 (而且這裡用的是 magic number)
五個地方,散落在三個 class 裡, 漏改任何一處, 要不是拋一個 ErrorMsg, 就是最終狀態不對, 這其實違反了 OCP(開放封閉原則), 對擴展開放、對修改封閉. 好的狀態機設計,加一個狀態應該只需要單純的新增, 不用到處改.
2. 用錯聚合 我個人覺得這才是核心的問題所在, 需求是會員對多家醫院掛號,最後選一家,其餘取消 , 這代表真正的業務主體是這一批掛號(Batch) ,不是某一家醫院的掛號單。
但原始設計把所有邏輯都塞在 HospitalAppointment(單筆掛號單)的狀態流轉裡,確認後取消其他這條跨單規則被硬擠進 StateService.handleSpecialLogic()內:
1 2 3 4 5 6 if (nextState.equals(AppointmentStateEnum.APPOINTMENT_CONFIRMED)) { }
這會造成並發不安全, 以及規則散落, 只能 confirm 一家醫院掛號是核心業務規則,它應該在 domain 裡被集中守護,而不是藏在 service 的某個 if 裡。今天一個新人接手(或是其他部門的同事來支援),會大幅度提高理解的時間, 最可怕的是修正後自己的業務可行, 但其他的業務反而被改壞了。
最後是外部 API 在 transaction 裡, 取消其他掛號時還要 call 醫院的取消 API,而這段 code 在 @Transactional 裡面, 如果 API call 超時 30 秒,你的 DB connection 就被 hold 住 30 秒;如果 API call 失敗,留下的選擇只有整個 transaction rollback.
3. God Service 這個是 java 開發的老毛病了, 一個 Service Method 扛一片天, 有沒有寫 unit test 一看就知道(如果是我也不寫, 自測 happy path 剩下的交給 QA)
但是他開發交付可以很快 (謎之聲)
看看 updateAppointmentState() 到底做了多少事:
載入掛號單
狀態遷移
DB update
特殊規則(confirm 後取消其他)
呼叫外部 hospital API
計算會員總狀態
更新會員狀態
七件事,一個 method,一個 @Transactional。這是典型的 God Service, 什麼都知道、什麼都做,正常測試的時候會需要 mock DB、mock 外部 API、mock 各種狀態分支,寫一個 unit test 可能比寫主邏輯還累。而且一旦未來要加 outbox pattern、事件通知、SLA 超時處理,程式通通落在這個 method 內,每次改都有機會影響既有邏輯(我真是最怕最怕就是這種事情)。
題外話, K 老弟當時就是在前面的 handleSpecialLogic 內補了一個 if, 他自己的流程完全正常, 但 calculateMemberStatus 直接破一個流程漏洞, 原先可以的流程直接亂掉, test 環境民怨四起, K 老弟只好退版, 在他自己的環境內去盤各種情境.
到這裡我們可以做個小結, 這段程式碼的問題根因是巢狀 switch 難擴展, 狀態轉換沒有被獨立封裝, 加需求就要改多處,漏改就爆開(還不是爆自己的), code 變動時靜默出錯, 跨單規則塞在單筆流程聚合根選錯(應該是 Batch), 並發不安全、規則散落 God Service, 沒有分離 application/domain/infrastructure 測試痛苦、交易邊界混亂.
盤點出問題在哪之後,來看看怎麼改。
重構思路 核心思路其實就是 SOLID, 讓每一層只做自己該做的事。
首先是狀態轉換, 從巢狀 switch 到宣告式 Transition Map .
原本的 AppointmentFlowManager 用巢狀 switch 表達狀態轉換,邏輯上沒有錯,但每次擴展都要改 switch 本體。我們應該要讓狀態 + 事件 -> 下一個狀態 這件事可以用宣告的方式來定義,也就是新增 transition 只要加一行,不用改既有程式碼.
這時年薪 200W up 的鄉民應該都想到了 State Pattern, GoF 經典款,藉由每個狀態獨立建一個 class,各自實作 onEvent() 方法, 這完全符合 OCP, 加新狀態就加新 class,不用改舊 code.
但是這個案例有一個疑慮, 就是我們可能會建立大量相似的 state class, 以這個掛號為例就有 11 個 class 要建立, 一看每個狀態的 handler 內實際就是一個 switch expression,只把事件對應到下一個狀態,沒有任何狀態特有的行為(例如進入時啟動排程、離開時清理資源等)這樣等於我只是把一個集中的 switch 拆成了 11 個分散的 switch 罷了。
State Pattern 最有價值的地方是當不同狀態有不同的行為邏輯,例如 RPA 處理中 進入時要啟動 RPA scheduler,等待醫院回應 進入時要設定 24 小時超時計時器。如果狀態真的有這種差異化行為,那 State Pattern 是正確的選擇。
但如果狀態轉換就是純粹的 A + event -> B 映射,那用一張宣告式的 transition table 就夠了,而且更加一目了然.
Transition Map 的做法是用一個 Map<(State, Event), NextState> 把所有合法的狀態轉移集中定義:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 public class AppointmentTransitions { private static final Map<StateEventKey, HospitalAppointmentState> TABLE = Map.ofEntries( entry(key(PENDING_DOCUMENT_VERIFICATION, DOC_VERIFICATION_SUBMIT), DOCUMENT_VERIFYING), entry(key(PENDING_DOCUMENT_VERIFICATION, DOC_MANUAL_VERIFY_SUBMIT), PENDING_HOSPITAL_RESPONSE), entry(key(DOCUMENT_VERIFYING, DOC_VERIFICATION_SUCCESS), PENDING_RPA_APPOINTMENT), entry(key(DOCUMENT_VERIFYING, DOC_VERIFICATION_FAILED), PENDING_MANUAL_VERIFICATION), entry(key(PENDING_MANUAL_VERIFICATION, MANUAL_VERIFY_APPROVE), PENDING_RPA_APPOINTMENT), entry(key(PENDING_MANUAL_VERIFICATION, MANUAL_VERIFY_REJECT), APPOINTMENT_FAILED), entry(key(PENDING_RPA_APPOINTMENT, RPA_START_APPOINTMENT), RPA_PROCESSING), entry(key(PENDING_RPA_APPOINTMENT, MANUAL_APPOINTMENT_SUBMIT), PENDING_HOSPITAL_RESPONSE), entry(key(RPA_PROCESSING, RPA_SUCCESS), PENDING_HOSPITAL_RESPONSE), entry(key(RPA_PROCESSING, RPA_FAILED), RPA_FAILED_PENDING_MANUAL), entry(key(RPA_FAILED_PENDING_MANUAL, MANUAL_APPOINTMENT_SUBMIT), PENDING_HOSPITAL_RESPONSE), entry(key(RPA_FAILED_PENDING_MANUAL, RPA_RETRY), RPA_PROCESSING), entry(key(RPA_FAILED_PENDING_MANUAL, GIVE_UP_APPOINTMENT), APPOINTMENT_FAILED), entry(key(PENDING_HOSPITAL_RESPONSE, HOSPITAL_APPROVE), APPOINTMENT_SUCCESS), entry(key(PENDING_HOSPITAL_RESPONSE, HOSPITAL_REJECT), APPOINTMENT_FAILED), entry(key(APPOINTMENT_SUCCESS, MEMBER_CONFIRM_HOSPITAL), APPOINTMENT_CONFIRMED), entry(key(APPOINTMENT_SUCCESS, MEMBER_CANCEL_APPOINTMENT), APPOINTMENT_CANCELLED) ); public static HospitalAppointmentState next (HospitalAppointmentState current, AppointmentEvent event) { HospitalAppointmentState next = TABLE.get(key(current, event)); if (next == null ) { throw new IllegalStateException ( "No transition defined: " + current + " + " + event); } return next; } private record StateEventKey (HospitalAppointmentState state, AppointmentEvent event) {} private static StateEventKey key (HospitalAppointmentState s, AppointmentEvent e) { return new StateEventKey (s, e); } }
對比一下差異:
巢狀 switch
Transition Map
新增一組 transition
找到對應的 case,插入新 case
加一行 entry(...)
一覽全部合法路徑
要展開整個 switch 慢慢看
一張表直接看完
不小心漏寫某個 transition
掉進外層 break,拋模糊 exception
TABLE.get() 回傳 null,明確報錯
未來需要狀態有行為差異
改 switch(又要動原本的)
可以針對特定狀態抽出 handler,漸進重構
而且這張表天生就是可測試的 , 這時就可以寫一個 parameterized test 把每組 (state, event, expectedNext) 跑一遍,確保所有 transition 都有被覆蓋。
聚合根歸位 原本確認後取消其他的邏輯散落在 StateService.handleSpecialLogic() 裡,我們把它收進 AppointmentBatch 這個聚合根:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 public class AppointmentBatch { private final String batchNo; private final String memberId; private final List<HospitalAppointment> appointments; private int version; public BatchConfirmResult confirmHospital (String targetAppointmentNo) { boolean alreadyConfirmed = appointments.stream().anyMatch(HospitalAppointment::isConfirmed); if (alreadyConfirmed) { throw new IllegalStateException ( "Already confirmed another hospital in batch " + batchNo); } HospitalAppointment target = findAppointment(targetAppointmentNo); if (!target.isSuccess()) { throw new IllegalStateException ( "Cannot confirm: appointment is " + target.getState()); } target.apply(HospitalAppointmentState.APPOINTMENT_CONFIRMED); List<String> cancelledNos = new ArrayList <>(); for (HospitalAppointment a : appointments) { if (!a.getAppointmentNo().equals(targetAppointmentNo) && a.isSuccess()) { a.cancelBecauseMemberConfirmedOther(); cancelledNos.add(a.getAppointmentNo()); } } return new BatchConfirmResult (cancelledNos); } public MemberAppointmentStatus calculateMemberStatus () { if (appointments.stream().anyMatch(HospitalAppointment::isConfirmed)) { return MemberAppointmentStatus.CONFIRMED; } if (appointments.stream().anyMatch(a -> a.getState() == HospitalAppointmentState.APPOINTMENT_SUCCESS)) { return MemberAppointmentStatus.PENDING_SELECTION; } if (appointments.stream().allMatch(a -> a.getState().isTerminal())) { return MemberAppointmentStatus.ALL_FAILED; } if (appointments.stream().allMatch(a -> a.getState() == HospitalAppointmentState.PENDING_DOCUMENT_VERIFICATION || a.getState() == HospitalAppointmentState.DOCUMENT_VERIFYING || a.getState() == HospitalAppointmentState.PENDING_MANUAL_VERIFICATION)) { return MemberAppointmentStatus.PENDING_VERIFICATION; } return MemberAppointmentStatus.PROCESSING; } private HospitalAppointment findAppointment (String appointmentNo) { return appointments.stream() .filter(a -> a.getAppointmentNo().equals(appointmentNo)) .findFirst() .orElseThrow(() -> new IllegalArgumentException ( "Appointment not found: " + appointmentNo)); } public record BatchConfirmResult (List<String> cancelledAppointmentNos) {} }
注意幾個重點:
calculateMemberStatus() 不再有 magic number , 判斷全部透過 enum 比對,而 isTerminal() 這種語意化方法定義在 enum 自身, 等於狀態的語意由狀態自己說了算,service 不需要知道哪些 code 代表終態.
只能 confirm 一家和 confirm 後取消其他家掛號被封裝在同一個方法裡 。任何人要做 confirm,都必須通過 AppointmentBatch.confirmHospital()執行,沒有繞過的空間.
UseCase 最後,再把 Application Layer 的 UseCase 從 God Service 拉出來瘦身:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 public class UpdateAppointmentStateUseCase { private final AppointmentRepository appointmentRepo; private final BatchRepository batchRepo; private final DomainEventPublisher eventPublisher; public void handle (String appointmentNo, AppointmentEvent event) { if (event == AppointmentEvent.MEMBER_CONFIRM_HOSPITAL) { handleConfirm(appointmentNo); return ; } HospitalAppointment appointment = appointmentRepo.getByNo(appointmentNo); HospitalAppointmentState next = AppointmentTransitions.next(appointment.getState(), event); appointment.apply(next); appointmentRepo.save(appointment); publishMemberStatusUpdate(appointment.getBatchNo()); } private void handleConfirm (String appointmentNo) { HospitalAppointment appointment = appointmentRepo.getByNo(appointmentNo); AppointmentBatch batch = batchRepo.getByBatchNo(appointment.getBatchNo()); AppointmentBatch.BatchConfirmResult result = batch.confirmHospital(appointmentNo); batchRepo.save(batch); for (String cancelNo : result.cancelledAppointmentNos()) { eventPublisher.publish( new CancelHospitalAppointmentRequested (cancelNo)); } publishMemberStatusUpdate(batch.getBatchNo()); } private void publishMemberStatusUpdate (String batchNo) { AppointmentBatch batch = batchRepo.getByBatchNo(batchNo); MemberAppointmentStatus status = batch.calculateMemberStatus(); eventPublisher.publish(new MemberStatusChanged (batch.getMemberId(), batchNo, status)); } }
對比一下原本的 God Service,這裡的職責切分是:
Class
職責
AppointmentTransitions
狀態 + 事件 -> 下一個狀態
HospitalAppointment
單筆掛號的狀態管理
AppointmentBatch
只能 confirm 一家、取消其他、算會員總狀態
UseCase
編排流程: 載入 -> 呼叫 domain -> 持久化 -> 發事件
CancelHandler(事件消費者)
在交易外呼叫醫院取消 API,可重試、可補償
外部 API 不再出現在 @Transactional 裡。取消掛號透過 Domain Event 發出,由獨立的 handler(可以是 MQ consumer 或 scheduled worker)在交易外執行,失敗可以重試、也可以進 dead-letter queue, 這裡就不贅述了.
結語 前幾天在瘦身 Rstar 專案的時候, 光是一個 Login 就搞得我焦頭爛額, 那天跟 K 老弟談到這件事時不知不覺聊了很多(酒也越喝越多, 聲音開始不受控制), K 老弟其實也有考慮要用 State Pattern 來重構, 但他建立到第四個 state class 的時候就開始懷疑自己了.
他跟我抱怨起前公司的架構和流程有多不健全, 說懷念當時一起在 SI 的時光, 雖然客戶的需求一直改設計一直變動, 但是所有的需求都會經過內化與評估後才轉化為開發規格, 現在各種場景都是靠幻想, 恍惚之間, 我看到了他頭頂上的鐵王冠…
啊, 原來他的 San 值歸零了啊…
自從加入軟體業, 我發現最大的問題不是不知道怎麼做, 而是不知道要做什麼, 尤其是現在有 AI 的幫助, 只要能寫出詳細的 prompt, 多數的問題都能夠被解決.
因此我其實不太喜歡評價別人的程式碼好壞, 因為我們無從知道開發當下的處境, 就拿 K 老弟處理的 巢狀 switch, 也許當時設計時預設就只會有兩三個流程, 需求文件就幾行字, 又或許只有半天的開發時間, 也許公司給的 pay 很差, 在這種情況之下, God Service & 巢狀 switch 反而成了最佳解, 而這也就是一個 Legacy Code 的誕生.