一個代理掛號系統的流程 Redesign

一個代理掛號系統的流程 Redesign

一月底的週末, 剛從菠菜離職的學弟 K君 約我吃了頓飯(看來是年終到手了是吧), 和我分享了這一段時間的生活還有工作近況, 在職場上遇到的鬼故事之類的.

依稀記得當年一起在 SI 公司工作的時候, 遇上需求談不清楚又容易情緒管理失控的 SA, 我們還一起把 teams 的頭像掛上 Darkest Dungeon 裡的 iron crown (大致上就是負面 San 值的 Debuff, 當精神理智降低時會出現).

後來, 他和我分享了一段流程狀態的設計, 我僅看了一眼, 那段程式碼就像鐵皇冠的印記一樣深深烙印在我的眼睛, 把我拖入瘋狂的深淵.

(以下的範例經脫敏設計, 主要為了體現奇異的流程狀態設計, 可能會有一些不符合現實需求的地方)


一個關於醫院代理掛號的服務

K 老弟的需求大概是這樣, 他的系統提供一個 web base 的服務, 協助會員在指定的日期區間中, 對多家醫院進行網路掛號, 會員最後再從掛號成功的醫院中, 選擇一家看病.

而系統的流程則是如下:

  1. 用戶輸入基本資料與上傳掛號文件
  2. 系統自動檢核文件, 若檢核失敗, 轉人工處理審核
  3. 審核通過, 系統啟動 RPA, 到各醫院的系統上進行掛號
  4. 若 RPA 失敗, 則轉人工操作掛號
  5. 掛號後, 系統會在會員的功能中顯示每個醫院的掛號狀態
  6. 會員選擇確切要掛號的醫院, 並取消其餘成功的掛號

恩… 聽起來沒啥問題, 整個需求也很合理方便, 感覺我媽就會想註冊會員, 說他多好多好用, 然後叫我幫忙操作.

而真正的問題還是要撥開橘子才知道…, 這一段邏輯大概由三段程式組成:

  1. Enum 狀態機
  2. Enum 事件與狀態
  3. 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);
}
// constructor, getter...
}
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;
// constructor, getter...
}

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) {
// 1. 查詢當前掛號單
HospitalAppointment appointment = hospitalAppointmentMapper.selectOne(
new QueryWrapper<HospitalAppointment>().eq("appointment_no", appointmentNo)
);

// 2. 取得當前狀態
AppointmentStateEnum currentState = AppointmentStateEnum.getByCode(appointment.getState());

// 3. 計算下一個狀態
AppointmentStateEnum nextState = flowManager.appointmentSubmit(currentState, event);

// 4. 更新掛號單狀態
appointment.setState(nextState.getCode());
appointment.setUpdateTime(LocalDateTime.now());
hospitalAppointmentMapper.updateById(appointment);

// 5. 處理特殊業務邏輯
this.handleSpecialLogic(appointment, nextState);

// 6. 計算並更新會員總狀態
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);

// 呼叫醫院 API 取消掛號
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))) { // 9=FAILED, 11=CANCELLED
return MemberAppointmentStatusEnum.ALL_FAILED;
}

// 已確認
if (stateList.stream().anyMatch(state -> state.equals(10L))) { // 10=CONFIRMED
return MemberAppointmentStatusEnum.APPOINTMENT_CONFIRMED;
}

// 待選擇醫院 (至少一個成功)
if (stateList.stream().anyMatch(state -> state.equals(8L))) { // 8=SUCCESS
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 沒過才轉人工

我就會需要改:

  1. AppointmentStateEnum:加一個 AI_REVIEWING 狀態
  2. AppointmentEvent:加 AI_REVIEW_SUCCESS、AI_REVIEW_FAILED 兩個事件
  3. AppointmentFlowManager:在 switch 裡加 AI_REVIEWING 的 case,同時改 DOCUMENT_VERIFYING 的 transition 指向
  4. StateService.handleSpecialLogic:如果 AI 審核有特殊邏輯,再補一段 if
  5. calculateMemberStatus:更新判斷條件 (而且這裡用的是 magic number)

五個地方,散落在三個 class 裡, 漏改任何一處, 要不是拋一個 ErrorMsg, 就是最終狀態不對, 這其實違反了 OCP(開放封閉原則), 對擴展開放、對修改封閉. 好的狀態機設計,加一個狀態應該只需要單純的新增, 不用到處改.

2. 用錯聚合

我個人覺得這才是核心的問題所在, 需求是會員對多家醫院掛號,最後選一家,其餘取消, 這代表真正的業務主體是這一批掛號(Batch),不是某一家醫院的掛號單。

但原始設計把所有邏輯都塞在 HospitalAppointment(單筆掛號單)的狀態流轉裡,確認後取消其他這條跨單規則被硬擠進 StateService.handleSpecialLogic()內:

1
2
3
4
5
6
if (nextState.equals(AppointmentStateEnum.APPOINTMENT_CONFIRMED)) {
// 查出其他掛號單
// 檢查有沒有已確認的
// 取消其他成功的
// 呼叫醫院 API
}

這會造成並發不安全, 以及規則散落, 只能 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() 到底做了多少事:

  1. 載入掛號單
  2. 狀態遷移
  3. DB update
  4. 特殊規則(confirm 後取消其他)
  5. 呼叫外部 hospital API
  6. 計算會員總狀態
  7. 更新會員狀態

七件事,一個 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; // 樂觀鎖

/**
* 會員確認某家醫院:只能 confirm 一家,其餘成功的標記取消
*/
public BatchConfirmResult confirmHospital(String targetAppointmentNo) {
// 1:不能重複確認
boolean alreadyConfirmed = appointments.stream().anyMatch(HospitalAppointment::isConfirmed);
if (alreadyConfirmed) {
throw new IllegalStateException(
"Already confirmed another hospital in batch " + batchNo);
}

// 2:目標必須是掛號成功的
HospitalAppointment target = findAppointment(targetAppointmentNo);
if (!target.isSuccess()) {
throw new IllegalStateException(
"Cannot confirm: appointment is " + target.getState());
}

// 執行:confirm target, cancel others
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) {}

// constructor, getters...
}

注意幾個重點:

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) {
// confirm 是批次操作,交給聚合根
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());

// 聚合根統一處理 confirm + cancel others
AppointmentBatch.BatchConfirmResult result = batch.confirmHospital(appointmentNo);

// 持久化整個 batch(含樂觀鎖檢查)
batchRepo.save(batch);

// 需要取消的掛號,發 domain event,在交易外處理
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 值歸零了啊…

Madness... our old friend

自從加入軟體業, 我發現最大的問題不是不知道怎麼做, 而是不知道要做什麼, 尤其是現在有 AI 的幫助, 只要能寫出詳細的 prompt, 多數的問題都能夠被解決.

因此我其實不太喜歡評價別人的程式碼好壞, 因為我們無從知道開發當下的處境, 就拿 K 老弟處理的 巢狀 switch, 也許當時設計時預設就只會有兩三個流程, 需求文件就幾行字, 又或許只有半天的開發時間, 也許公司給的 pay 很差, 在這種情況之下, God Service & 巢狀 switch 反而成了最佳解, 而這也就是一個 Legacy Code 的誕生.