OOP 與 SOLID:從 Controller–Service–Repository 到 Clean Architecture

好幾年前在之前的公司曾帶過幾個資策會出來的學弟。

記得剛 On Board 時我給他們幾個作業,Code Review 的時候也特別強調「要符合專案的架構風格與一些設計原則」。其實對 Junior 來說,他們以前專注的是「程式能不能跑、結果對不對」,效能、維護性、延展性這些詞,對當時的他們來說太遠了。

另外套句我碩士教授說的:

「你們那個系統沒多少人用,吹毛求疵那個效能幹嘛,不如把時間花在其他事情上。」

如今我已經在能反駁他的職位與工作需求上,發現畢業七八年後的我才真正體會那句話背後的現實。

軟體一旦上線,性能與維護都是長期成本。好的程式能提升協作效率,能降低 review、除錯、交接甚至是升級的時間。

OOPSOLID,正是這些「長期維護成本」的根本解方。


OOP:物件導向程式設計的起點

OOP 的四大核心是:

封裝(Encapsulation)、抽象(Abstraction)、繼承(Inheritance)、多型(Polymorphism)

目的是讓系統更容易維護、復用與擴充。

範例:顧客下單(Place Order)

以下是一個典型的 Spring 三層架構範例:

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
@RestController
@RequiredArgsConstructor
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;

@PostMapping
public PlaceOrderRs place(@RequestBody PlaceOrderRq rq) {
return orderService.place(rq);
}
}

@Service
@RequiredArgsConstructor
public class OrderService extends BaseService {
private final ItemRepository itemRepository;
private final OrderRepository orderRepository;
private final RabbitTemplate rabbitTemplate;

private static final String ORDER_EXCHANGE = "order.events";
private static final String ORDER_PLACED_RK = "order.placed";

@Transactional
public PlaceOrderRs place(PlaceOrderRq rq) {
require(rq != null, "request must not be null");
require(!rq.getLines().isEmpty(), "lines must not be empty");

BigDecimal total = BigDecimal.ZERO;
List<OrderLineEntity> lines = new ArrayList<>();

for (OrderLineRq line : rq.getLines()) {
var item = itemRepository.findById(Long.parseLong(line.getItemId()))
.orElseThrow(() -> bad("item not found: " + line.getItemId()));

require(line.getQty() > 0, "qty must > 0");

BigDecimal amount = item.getPrice().multiply(BigDecimal.valueOf(line.getQty()));
total = total.add(amount);
lines.add(new OrderLineEntity(item.getId(), item.getName(), item.getPrice(), line.getQty()));
}

OrderEntity order = new OrderEntity();
order.setTotal(total);
order.setStatus("PLACED");
order.setLines(lines);

updateAuditFields(order); // 審計欄位統一更新

var saved = orderRepository.save(order);
publishOrderPlaced(saved);
return new PlaceOrderRs(saved.getId().toString(), total);
}

private void publishOrderPlaced(OrderEntity order) {
var event = new OrderPlacedEvent(order.getId(), order.getTotal(),
order.getLines().stream()
.map(l -> new OrderPlacedEvent.Line(l.getItemId(), l.getItemName(), l.getPriceSnapshot(), l.getQty()))
.toList());
rabbitTemplate.convertAndSend(ORDER_EXCHANGE, ORDER_PLACED_RK, event);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Slf4j
public abstract class BaseService {
@Autowired protected Clock clock;

protected void updateAuditFields(Auditable entity) {
var now = LocalDateTime.now(clock);
if (entity.getCreatedAt() == null) entity.setCreatedAt(now);
entity.setUpdatedAt(now);
}

protected void require(boolean condition, String msg) {
if (!condition) throw new IllegalArgumentException(msg);
}

protected RuntimeException bad(String msg) {
return new IllegalArgumentException(msg);
}
}

三層架構下的 OOP

封裝 (Encapsulation) 的概念是把資料與行為包在一起,外界只透過方法使用物件,不直接操作內部狀態

例如 OrderLineEntity 內有 private 的 total 物件, 並提供 public 的 getter, setter 方法調用;OrderService 封裝下單流程,Controller 只要呼叫 place()。藉由封裝可以隱藏內部實作,只暴露必要介面,也就是說,外部只應該看到物件能做什麼,而不需要知道它怎麼做。

抽象 (Abstraction) 隱藏細節,讓使用者只看到介面層次。 Controller 不知道 DB 細節,只知道有個 OrderService.place(), 而 Service 只依賴抽象的 PaymentRepository,不在乎底層是 MySQL、PostgreSQL 或是外部 API。 這樣一來,當需求變動時,我們能在不修改 Service 的情況下自由替換底層實作。

繼承 (Inheritance) 繼承的目的,是共用行為並延伸差異。這裡的 updateAuditFields 就是繼承 (Inheritance) 的實例,統一管理審計欄位更新,而 OrderService 在下單邏輯中直接呼叫它完成欄位更新。

多型 (Polymorphism) 同一介面可有不同實作,執行時依物件而異。 不同 OrderRepository(JPA、MyBatis、Mock)都能被注入運作。


OOP 的侷限

上述是常見的三層架構, 好讀好理解, 但其實暗藏地雷:

  • OrderService 同時負責驗證、計算、組訂單、發事件,違反單一職責原則。
  • 若要加上「折扣」、「定價策略」或「不同事件通道」,都得改動 OrderService
  • 測試難度高,耦合嚴重,會讓大家只想寫 Happy Path。

接下來,我們用 SOLID 原則 將這段程式「演化」成可長期維護的架構。


SOLID:讓 OOP 真正能長大的五個原則

重構前後對照:從 Controller-Service 到 Clean Architecture

重構前 - 傳統三層架構:

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
@RestController
@RequiredArgsConstructor
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;

@PostMapping
public PlaceOrderRs place(@RequestBody PlaceOrderRq rq) {
return orderService.place(rq);
}
}

@Service
@RequiredArgsConstructor
public class OrderService extends BaseService {
private final ItemRepository itemRepository;
private final OrderRepository orderRepository;
private final RabbitTemplate rabbitTemplate;

@Transactional
public PlaceOrderRs place(PlaceOrderRq rq) {
// 混合了驗證、計算、組裝、保存、發事件等多重職責
require(rq != null, "request must not be null");
require(!rq.getLines().isEmpty(), "lines must not be empty");

BigDecimal total = BigDecimal.ZERO;
List<OrderLineEntity> lines = new ArrayList<>();

// 價格計算邏輯硬編碼在服務中
for (OrderLineRq line : rq.getLines()) {
var item = itemRepository.findById(Long.parseLong(line.getItemId()))
.orElseThrow(() -> bad("item not found: " + line.getItemId()));
require(line.getQty() > 0, "qty must > 0");

BigDecimal amount = item.getPrice().multiply(BigDecimal.valueOf(line.getQty()));
total = total.add(amount);
lines.add(new OrderLineEntity(item.getId(), item.getName(), item.getPrice(), line.getQty()));
}

OrderEntity order = new OrderEntity();
order.setTotal(total);
order.setStatus("PLACED");
order.setLines(lines);
updateAuditFields(order);

var saved = orderRepository.save(order);
publishOrderPlaced(saved); // 直接依賴具體的 RabbitMQ
return new PlaceOrderRs(saved.getId().toString(), total);
}
}

重構後 - Clean Architecture:

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
// Controller 保持精簡,專注於 HTTP 相關事務
@RestController
@RequiredArgsConstructor
@RequestMapping("/orders")
public class OrderController {
private final PlaceOrderUseCase placeOrderUseCase;

@PostMapping
public PlaceOrderResponse place(@RequestBody PlaceOrderRequest request) {
var command = new PlaceOrderCommand(
request.customerId(),
request.lines().stream()
.map(l -> new PlaceOrderCommand.PlaceOrderLine(l.itemId(), l.qty()))
.toList()
);
var result = placeOrderUseCase.execute(command);
return new PlaceOrderResponse(result.orderId(), result.total());
}
}

// UseCase 專注於業務流程協調
@Service
@RequiredArgsConstructor
public class PlaceOrderUseCaseImpl implements PlaceOrderUseCase {
private final ItemGateway itemGateway;
private final OrderGateway orderGateway;
private final EventPublisher eventPublisher;
private final PricingPolicy pricingPolicy;
private final Clock clock;

@Transactional
public PlaceOrderResult execute(PlaceOrderCommand cmd) {
// 使用領域模型封裝業務邏輯
var order = new Order();
for (var line : cmd.lines()) {
var item = itemGateway.findById(Long.parseLong(line.itemId()))
.orElseThrow(() -> new IllegalArgumentException("item not found"));
var unitPrice = pricingPolicy.unitPriceFor(item, line.qty());
order.addLine(item, line.qty(), unitPrice); // 業務邏輯移至領域模型
}

order.place(); // 領域行為
order.touch(LocalDateTime.now(clock));

var saved = orderGateway.save(order);
eventPublisher.publishOrderPlaced(saved);

return new PlaceOrderResult(saved.getId().toString(), saved.getTotal());
}
}

// 豐富的領域模型
public class Order {
private OrderId id;
private List<OrderLine> lines = new ArrayList<>();
private BigDecimal total = BigDecimal.ZERO;
private OrderStatus status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;

public void addLine(Item item, int quantity, BigDecimal unitPrice) {
require(quantity > 0, "Quantity must be positive");
require(unitPrice.compareTo(BigDecimal.ZERO) >= 0, "Unit price must be non-negative");

var line = new OrderLine(item.id(), item.name(), unitPrice, quantity);
lines.add(line);
total = total.add(line.subTotal());
}

public void place() {
require(!lines.isEmpty(), "Cannot place order with no lines");
this.status = OrderStatus.PLACED;
}

// 新增 touch 方法來處理審計欄位更新
public void touch(LocalDateTime timestamp) {
if (this.createdAt == null) {
this.createdAt = timestamp;
}
this.updatedAt = timestamp;
}

// 封裝內部實現細節
public BigDecimal getTotal() { return total; }
public OrderStatus getStatus() { return status; }
}

對照分析

維度 重構前 (Controller-Service) 重構後 (Clean Architecture)
職責分離 Service 承擔多重職責 UseCase 協調,Domain 封裝邏輯
業務邏輯 散落在 Service 方法中 集中在領域模型內
測試難度 需要整合測試,耦合嚴重 可單元測試各組件
擴充性 修改現有類別 新增策略類別,符合開放封閉
依賴方向 依賴具體框架技術 依賴抽象接口

S:單一職責原則(Single Responsibility)

重構前問題: OrderService 同時負責:

  • 請求驗證
  • 價格計算
  • 訂單組裝
  • 資料保存
  • 事件發布

重構後解決:

  • PlaceOrderUseCase:協調業務流程
  • Order 領域模型:封裝訂單業務規則
  • PricingPolicy:專注定價策略
  • EventPublisher:處理事件發布

O:開放封閉原則(Open/Closed Principle)

重構前問題: 新增定價策略需要修改 OrderService

1
2
3
4
5
6
7
8
// 舊方式:在 Service 中硬編碼判斷
if (isCampaignPeriod()) {
price = item.getPrice().multiply(new BigDecimal("0.9"));
} else if (isMemberPrice(user)) {
price = item.getPrice().multiply(new BigDecimal("0.95"));
} else {
price = item.getPrice();
}

重構後解決: 透過策略模式擴充:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface PricingPolicy {
BigDecimal unitPriceFor(Item item, int qty);
}

@Component
class StandardPricingPolicy implements PricingPolicy {
public BigDecimal unitPriceFor(Item item, int qty) {
return item.price();
}
}

@Component
class MemberPricingPolicy implements PricingPolicy {
public BigDecimal unitPriceFor(Item item, int qty) {
return item.price().multiply(new BigDecimal("0.95"));
}
}

L:里氏替換原則(Liskov Substitution Principle)

重構前問題: 直接依賴 Spring Data JPA:

1
private final OrderRepository orderRepository;  // Spring Data 接口

重構後解決: 自定義 Gateway 接口:

1
2
3
4
5
6
7
8
9
10
public interface OrderGateway {
Order save(Order order);
Optional<Order> findById(OrderId id);
}

@Repository
class JpaOrderGateway implements OrderGateway {
private final SpringDataOrderJpa jpa;
// 實作細節封裝在基礎設施層
}

I:介面隔離原則(Interface Segregation Principle)

重構前問題: Repository 介面可能過於龐大:

1
2
3
public interface OrderRepository extends JpaRepository<OrderEntity, Long> {
// 可能包含數十個方法,即使某些 UseCase 只需要其中幾個
}

重構後解決: 按職責分離接口:

1
2
3
4
5
6
7
8
9
10
11
public interface ItemGateway {
Optional<Item> findById(Long id);
}

public interface OrderGateway {
Order save(Order order);
}

public interface EventPublisher {
void publishOrderPlaced(Order order);
}

D:依賴反轉原則(Dependency Inversion Principle)

重構前問題: 高層模組依賴低層模組:

1
2
3
Controller → Service → Repository (JPA) → Database

RabbitTemplate → Message Queue

重構後解決: 高層定義接口,低層實現:

1
2
3
Controller → UseCase (業務邏輯)

Ports (接口定義) ← Adapters (實作)

結論, 追求更好的環境

最後整理了結論:

  • 三層架構在專案初期很快,但當業務複雜度成長時,修改一個功能可能引發多處變動
  • Clean Architecture 透過依賴反轉和介面隔離,讓系統能夠優雅地適應變化
  • 清晰的架構邊界讓多人開發時減少衝突
  • 新成員能透過架構快速理解業務邏輯的分佈
  • 重構前:OrderService 的測試需要準備大量 Mock,測試程式碼複雜
  • 重構後:每個組件都可以獨立測試,測試程式碼更專注、更簡單
  • 當需要更換資料庫、消息隊列或框架時,Clean Architecture 讓這些變更侷限在基礎設施層,不影響核心業務邏輯

回頭看教授當年的那句話:「你們那個系統沒多少人用,吹毛求疵那個效能幹嘛」,我現在有了更深的理解。他說的其實是成本效益的權衡——在資源有限的情況下,應該把精力花在刀刃上。

說實在, 如果三層式架構這麼多毛病, 怎麼還沒被淘汰呢? 反過來說, 若三層式架構就足夠應付業務邏輯, 還能快速交付, 又為何不使用呢?

在這種環境下,我們也只能更努力的去追求更好, 資源與需求更大的企業,然後保持學習與對程式品質的初心。