DDD × Clean Architecture 的實戰筆記 | 一次登入流程帶來的架構重構

DDD × Clean Architecture 的實戰筆記 | 一次登入流程帶來的架構重構

2025 年我完成了 ColorlyCasha 這兩個專案, 同時也靠著 Vibe Code 在短時間內寫了兩個性質相似的 Backoffice Web Service(就是後台網站啦), 起初所有的作法都來自過往參的專案架構與經驗, 搭配 AI 前期用不可思議的速度在開發, 但到後來開始有些微失控, 不過這兩個專案也剛好到了不會特別再更新的規模.

為了 2026 新的專案開發以及不重蹈覆轍, 我得到了兩個結論:

  • Vibe Coding 需要健全的文件支持
  • 該整理自己的 Library 了

過往在 SYSTEX 和 IBM 時都有搭建過全新的專案, 基本上啟動都是拿一個現成的同性質專案, 一步一步的搬磚, 再慢慢加入各個訂製的 Feature, 但是底層基本上是相同的; 這時我就想, 何不建立一個開箱即用的 seed project, 具備基本的後台登入驗證功能, 並支援單體與微服務下的開發模式, 完成後一鍵改包名, 成為其他專案的基底, 讓我的 Side Project 遍地開花.

因此 Rstar 這個專案就誕生了, 這裡也下一個 Flag, 我 打完這場仗就要回老家跟女朋友結婚了 JoJo 預計使用 Rstar 開發 Colorly Lite 以及其他 2026 專案的後台.

而針對 Vide Coding, 我搭建了 Rstar 的 conventions Wiki, 目前還在編寫與測試中, 但先開放給大家觀看。

殊不知, 這些規定就是我崩潰的開始…

Rstar Clean Architecture

目前所有的架構與規定都寫在 Conventions Wiki 之中, Wiki 我盡量寫的詳細完整, 有興趣可以去深讀。

這裡就介紹關於 Component 的那一部份:

  • Controller: 為系統的對外入口,負責接收 API 請求並轉交對應的 Use Case.
    • 不得包含任何業務邏輯
    • 不得直接呼叫 Service、Repository 或 Adapter
    • 不得處理交易或流程判斷 (No if else)
    • Controller 必須維持為 Thin Adapter。
  • Use Case: 代表一個明確的使用者或系統行為,為應用層的核心.
    • 必須為介面(interface)
    • 一個 Use Case 對應一個 API 行為
  • Use Case Implementation: 為 Use Case 的具體實作.
    • 不得包含基礎建設實作細節
    • 不得直接操作 Framework API
    • UseCaseImpl 是應用流程的唯一擁有者
  • Service: Service 承載可重用的業務邏輯與子流程
    • 不得依賴 Controller 或 Use Case
    • 可依賴 Repository 或 Adapter 介面
    • 不負責跨 Use Case 的流程協調
  • Repository: 定義資料存取的抽象介面.
    • 必須為介面
    • 不得包含業務邏輯
    • 不得暴露 ORM / SQL 細節
    • 過於複雜的 SQL 操作可以用 RepositoryImpl 實現
  • Adapter 定義與外部系統或基礎建設的抽象互動介面.
    • 抽象 HTTP、RPC、MQ、Cache 等外部依賴

從上可見, 整個元件的依賴方向就是:

Controller -> UseCase -> UseCaseImpl -> Service, Repository, Adapter

到這裡看起來都沒問題 (掉書袋誰都會), 鋪陳到這裡結束, 來開始一步一步的跟我審視與重構 Casha 的入流程.

審視 Casha 的 Login

首先看一下這個 Login API

POST /auth/login

內容為用戶在 Saas 中以帳號密碼登入, 需檢驗傳參是否為空, 若帳號密碼相符, 則將該用戶的基本資料, 權限列表和 JWT 回傳; 若驗證失敗, 則返回失敗, 並記錄失敗次數, 當失敗達三次則 Ban 該帳戶 20 分鐘.

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RequestMapping("/auth")
@RestController
public class SaasLoginController {

@Autowired
SaasLoginUseCase saasLoginUseCase;

/**
* 平台用戶 login
* @param request
* @return
*/
@PostMapping("/login")
public ResponseEntity<ApiResponse<SaasLoginRs>> adminPortalLogin(@RequestBody @Valid SaasLoginRq request) {

SaasLoginRs rs = saasLoginUseCase.loginByUserNameAndPwd(request);

return ResponseEntity.ok()
.header(HttpHeaders.AUTHORIZATION, "Bearer " + rs.getAccessToken())
.body(ApiResponse.success(rs));
}
}

UsecaseImpl

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
@Service
@RequiredArgsConstructor
public class SaasLoginUseCaseImpl implements SaasLoginUseCase {

private final CashaUserRepository cashaUserRepository;
private final PasswordEncoder passwordEncoder;
private final LoginSessionBuilderService loginSessionBuilderService;

@Autowired
@Qualifier("redisTemplateString")
private RedisTemplate<String, String> redisTemplate;

@Override
public SaasLoginRs loginByUserNameAndPwd(SaasLoginRq rq) {

// 查詢出用戶
CashaUser user = cashaUserRepository.findByUserAccount(rq.getUserAccount())
.map(CashaUser::fromEntity)
.orElseThrow(() -> new AuthServiceException(AuthServiceException.AuthServiceErrorType.USER_NOT_FOUND));

// 登入失敗三次,阻擋 20 分鐘
if (isAccountLoginFailOverThreeTimes(user.getUserAccount())) {
throw new AuthServiceException(AuthServiceException.AuthServiceErrorType.LOGIN_FAIL_OVER_THREE_TIME);
}

if (!user.isCredentialsValid(rq.getPasswd(), passwordEncoder)) {
// 登入失敗 → 紀錄登入錯誤次數
incrementLoginFailCount(rq.getUserAccount());

throw new AuthServiceException(AuthServiceException.AuthServiceErrorType.ACCOUNT_PASSWD_NOT_MATCH);
}

// 用戶帳號被鎖
if (!user.isActive()) {
throw new AuthServiceException(AuthServiceException.AuthServiceErrorType.ACCOUNT_BANNED);
}

// 成功登入 → 清除登入錯誤紀錄
clearLoginFailCount(rq.getUserAccount());

return loginSessionBuilderService.buildLoginSession(user);
}

...
}

LoginSessionBuilderService.buildLoginSession 則是從 DB 內組合出 UserRoleList, MenuList, 返回前端, 並儲存 UserSession 到 Redis 內, 這裡不展示細節.

以上便是 Casha Login 的方法, 看起來挺 ok 的, 但搬磚的時候考量到自己寫的規範, 問題變逐一浮現.

1. Naming 引發的問題

目前的命名方式是:

1
2
3
4
SaasLoginController.adminPortalLogin
→ saasLoginUseCase.loginByUserNameAndPwd
→ userRepository.findByUserAccount
→ loginSessionBuilderService.buildLoginSession

首先是 Controller 和 UseCase, 在 adminPortalLogin 的方法後接 loginByUserNameAndPwd 概念上 usecase 已經表達了實作的細節, 而不太像用戶的意圖, 會讓 usecase 綁定實作辦法, 所以看到程式內同時做了登入驗證, 失敗情境的實作.

這時我靈光一閃, 如果後面我增加了 otp 驗證的 feature, 那豈不是只剩下開 loginByOtp controller & usecase 一條路了? 而且, isAccountLoginFailOverThreeTimes()incrementLoginFailCount() 這個方法也需要重新打包, 否則我只能整個複製到新的 usecase 內, 這樣哪天我要把 login fail tolerance 從 3 改到其他數字時, 我就必須要檢查多個 usecase 一起更新, 這好像是 DDD 內經典的退化案例.

我只是想改個名字, 結果發現 usecase 竟然想偷偷轉職成 Service, 說好的 Clean Architecture 直接變成 3 Layer MVC.

2. 決策反思

目前的 loginByUserNameAndPwd 其實已經不太像 usecase, 而更像是一個混合了驗證策略、帳號安全規則與流程控制的 Service, 如果我繼續沿著這條路走, 未來每增加一種登入方式, 幾乎只剩下再開一組 Controller 與 usecase 這個解法,最後只會讓同一組登入規則分散在多個地方,維護成本急速上升。

這時我意識到問題並不在於要不要多開 api, 而是在於 usecase 是否被迫承擔了不該屬於它的變化, 如帳密、OTP、第三方登入,這些都是如何驗證的差異,而不是是否登入的差異。

最後我選擇 refactor SaasLoginUseCase,讓 usecase 只負責協調登入流程本身,至於驗證方式、失敗次數限制、帳號封鎖等策略,則下沉到 Domain Service 內部處理。讓 usecase 變得單純, 一目瞭然.

至於 OAuth,我會選擇開設新的 api。因為 OAuth 的流程本質上是由外部系統驅動, 如 redirect、callback 與 token exchange,它的使用情境與系統邊界本來就與一般登入不同. 強行把它塞進同一個入口,只會讓 Controller 與 usecase 的語意再次變得模糊。

UseCase 對齊行為的一致性,而 api 則可因情境分開

開始 Refactor usecase

有了前面的概念後, 便可以開始解耦 usecase.

1. Usecase 瘦身

這裡我建立了 AuthService 來驗證登入請求, 並把全部的策略與方法下沉到內部, 整個 usecase 變得非常乾淨.

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
@Service
@RequiredArgsConstructor
public class BsLoginUseCaseImpl implements BsLoginUseCase {

private final AuthService authService;
private final LoginSessionBuilderService loginSessionBuilderService;
private final BsUserRepository bsUserRepository;

@Override
public BsLoginRs adminPortalLogin(BsLoginRq rq) {

// 查詢出用戶
BsUser user = bsUserRepository.findByUserAccount(rq.getUserAccount())
.map(BsUser::fromEntity)
.orElseThrow(() -> new AuthServiceException(AuthServiceException.AuthServiceErrorType.USER_NOT_FOUND));

// 驗證登入請求
authService.authenticate(user, rq);

// 建立 Role, User Session
LoginSessionDto loginSessionDto = loginSessionBuilderService.buildLoginSession(user);

// 返回 Rs
return buildBsLoginRs(loginSessionDto);
}
...
}

2. AuthService 的策略模式

AuthService 內實踐了驗證失敗的流程與邏輯, 並採用策略模式(Strategy Pattern), 把驗證方法的選擇與實作再往下給 LoginStrategyResolver & BsLoginStrategyImpl 執行.

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
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthService {

private final LoginStrategyResolver loginStrategyResolver;
private final RedisTemplate<String, String> redisTemplate;

private static final String LOGIN_FAIL_KEY_PREFIX = "auth:fail:";
private static final String LOGIN_LOCK_KEY_PREFIX = "auth:lock:";

/**
* 登入驗證
* @param user
* @param rq
*/
public void authenticate(BsUser user, BsLoginRq rq) {

BsLoginStrategy strategy = loginStrategyResolver.resolve(rq);

// 判斷是否已登入失敗三次,阻擋 20 分鐘
if (isAccountLoginFailOverThreeTimes(user.getUserAccount())) {
throw new AuthServiceException(AuthServiceException.AuthServiceErrorType.LOGIN_FAIL_OVER_THREE_TIME);
}

// 交由 strategyImpl 驗證
try {

strategy.verify(user, rq);

}catch(InvalidCredentialException e){
log.info("Login Failed");
incrementLoginFailCount(rq.getUserAccount());
throw new AuthServiceException(AuthServiceException.AuthServiceErrorType.ACCOUNT_PASSWD_NOT_MATCH);
}

clearLoginFailCount(rq.getUserAccount());

// 用戶帳號被鎖
if (!user.isActive()) {
throw new AuthServiceException(AuthServiceException.AuthServiceErrorType.ACCOUNT_BANNED);
}

}
...
}

此時這裡的 Redis 也開始出現幾個警訊:

  • AuthService 開始知道 Redis key 的格式
  • 這些方式不是會被復用
  • 如果置換 redis 成其他 nosql 怎辦(不過我的專案定義不會, 防腐層可以不計)
  • 測試成本開始上升

總有種感覺, 未來一定會有人動這一層, 我開始考慮是否要把 RedisTemplate 包成 Repository, 但審視了一下, 目前 Redis 並不是系統事實來源(Source of Truth), 更像是計數器, 目前是可以接受的, 但當 Redis 方法具備明確的語意模型時, 就有必要考慮建立 Repository, 所以先當作一個延後決策點.

而且多人開發恐怖的是相同的邏輯在各地都有, 未來更新時要東查西找 (但也常見寫出來了沒人使用, 通常是方法寫在一些不相干的 Helper or Service 內, 因為跟 usecase 過度綁定, 導致大家找不到就自己又寫一套).

3. StrategyResolver & StrategyImpl

這兩個就相對單純了, Resolver.resolve 撰寫辨別 Implement 的方法, 例如後續若加入 otp, 可以從 rq 內取得 Validate Type 等邏輯; 而 StrategyImpl 則是實作真正驗證的方法.

1
2
3
4
5
6
7
8
9
10
11
@Component
@RequiredArgsConstructor
public class LoginStrategyResolver {

private final PasswordLoginStrategy passwordLoginStrategy;

public BsLoginStrategy resolve(BsLoginRq rq) {
return passwordLoginStrategy;
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
@RequiredArgsConstructor
public class PasswordLoginStrategy implements BsLoginStrategy {

private final PasswordEncoder passwordEncoder;

@Override
public void verify(BsUser user, BsLoginRq rq) {

// 密碼不正確
if (!passwordEncoder.matches(rq.getPasswd(), user.getEncodedPasswd())) {
throw new InvalidCredentialException();
}
}

}

另外一點是在 StrategyImpl 中我額外建立了一個 RuntimeException InvalidCredentialException, 眼尖的鄉民們可能有注意到, 我在驗證失敗的時候拋出他, 並在 AuthService 內 catch 住, 最後再轉換為對外的 CustomException, 等於是在 AuthService 這一層才去決定是否要累加失敗次數, 觸發封鎖等機制.

這樣的切分有幾個好處, 首先 StrategyImpl 不會被迫認識對外的錯誤模型, 驗證邏輯可以被重用在不同登入流程中, 測試時也可以獨立驗證驗證失敗失敗後處理兩個情境.

換句話說,InvalidCredentialException 並不是錯誤處理的終點,而是一個領域內部的控制訊號,用來把流程導回真正該負責決策的地方.

1
2
3
4
5
6
public class InvalidCredentialException extends RuntimeException {

public InvalidCredentialException() {
super();
}
}

結語

其實一開始我只是想趁搬專案時把 Login 寫乾淨一點,但最後動到的卻是整個登入流程的責任邊界.

這樣一頓操作下來, usecase 的責任被收斂, 變化被集中在該承擔的地方, 並且測試與理解成本下降, 特別是測試這段, 登入驗證和策略都能獨立測試, Service 間也不太需要複雜的 mock, 搞到我之後都想寫一篇文章來說明了 (2026 第二個 Flag, 寫完這篇我就要光榮退休領終身俸享福了 嘗試看看今年九月再去參加一次 IT 鐵人賽).

如果把這次的重構拉高一點來看,會發現 Clean Architecture 的本質,某種程度上就是在落實 SOLID:

單一職責(SRP):
Usecase 不再同時負責驗證、政策與流程; Strategy 只關心驗證; AuthService 才是決策中心.

開放封閉(OCP):
新的登入方式只需要新增 Strategy,而不需要改動既有流程.

依賴反轉(DIP):
高層流程不再依賴具體的驗證實作,而是依賴抽象的策略介面.

Clean Architecture 並不是一套照抄的規範,而是一面鏡子,在認真實作時把設計上的模糊與問題全部照出來

這次的分享大概就到這裡, 這還僅僅是 Rstar 專案的第一個 Login api, 就發現如此多的問題與改良, 我想 2026 會是個充滿挑戰與收穫的一年吧~~

當然, 再當我打開 LoginSessionBuilderService.buildLoginSession 這個 Service 時, 又是另外一回事了…