CRUD-based Interface#

企業級開發中最常見的問題之一,就是程式設計師處理資料修改的方式。所有操作在技術上都可以歸類為 Create、Read、Update、Delete(CRUD)四種類別,但以 CRUD 來組織應用程式並非好主意。這種做法稱為 CRUD-based interface,其背後的思維模式稱為 CRUD-based thinking

CRUD-based Interface 的三大問題#

1. 複雜度不受控增長(Growth of Complexity)

  • 將所有修改操作塞進單一 method(例如一個巨大的 update method)會導致該 method 急速膨脹
  • 複雜度增加後,bug 數量隨之上升,也更難維護

2. 領域專家的視角與實作脫節(Experts’ View != Implementation)

  • 領域專家不會用 CRUD 術語來描述操作:他們會說「register(註冊)」而非「create」,說「enroll(選課)」而非「update」
  • 缺乏 Ubiquitous Language(統一語言)會導致程式設計師與領域專家之間的溝通成本增加
  • Ubiquitous Language 是 Domain-Driven Design 的三大支柱之一(另外兩個是 Bounded Contexts 和 Focus on the Core Domain)

3. 損害使用者體驗(Damaging the User Experience)

  • CRUD-based thinking 不僅影響程式碼,也會蔓延到 UI
  • 使用者必須自行探索介面上的功能,就像偵探一樣去推斷每個操作的意義
  • 例如:在一個 Update Student 的畫面中,至少混合了四種不同的功能(編輯個人資訊、選課、轉課、退課)

CRUD-based interface 同時影響 APIUI。兩者互相影響:如果其中一個有問題,另一個幾乎也必然有同樣的問題。

CRUD-based Thinking 的根源#

CRUD-based thinking 源自程式設計師想要統一一切的慾望。就像 OOP 認為「一切都是物件」,CRUD-based thinking 認為「一切都是 CRUD」。從程式設計師角度看,這似乎產生了整齊的系統,但這並不能真正反映領域的需求。正確做法是與領域專家交談,發掘適當的術語,並據此調整思維。

Task-based Interface#

Task-based interface 是 CRUD-based interface 的對立面。它的核心概念是:辨識使用者能對物件執行的每一項任務,並為每項任務分配獨立的視窗和獨立的 API endpoint。

  • 每個視窗只負責一件事,恢復 Single Responsibility Principle
  • 程式碼大幅簡化
  • 使用者體驗顯著改善——每個畫面都是自我描述且直觀的

實作 Task-based interface 時,你必須深入研究每項功能對領域專家的意義,這會自然而然地加深你對問題領域的理解,並讓程式碼更貼近 Ubiquitous Language。

拆解 Update Method#

原本的 Update method 是 object-centric(以物件為中心)的設計,試圖在單一畫面處理整個 Student 物件的所有修改。我們需要將它拆分為 task-centric(以任務為中心)的設計,每個任務各自獨立。

Enroll(選課)#

第一個提取出來的方法是 Enroll。它擁有自己的 API endpoint 和專用的 DTO:

@PostMapping("{id}/enrollments")
public void enroll(@PathParam("id") long id,
        @RequestBody StudentEnrollmentDto dto) {
    Student student = studentRepository.getById(id);
    if (student == null)
        return Error("No student found with Id '{id}'");

    Course course = courseRepository.getByName(dto.getCourse());
    if (course == null)
        return Error("Course is incorrect: '{dto.getCourse()}'");

    boolean success = Enum.tryParse(dto.getGrade());
    if (!success)
        return Error("Grade is incorrect: '{dto.getGrade()}'");

    student.enroll(course, grade);
    unitOfWork.commit();
}
public class StudentEnrollmentDto {
    private String course;
    private String grade;
}

每個新的 endpoint 都有自己專屬的 DTO,只包含該任務所需的欄位。

Transfer(轉課)#

Transfer endpoint 接受 student id、enrollment number 和一個 StudentTransferDto

@PutMapping("{id}/enrollments/{enrollmentNumber}")
public void transfer(@PathParam("id") long id,
        @PathParam("enrollmentNumber") int enrollmentNumber,
        @RequestBody StudentTransferDto dto) {
    Student student = studentRepository.getById(id);
    if (student == null)
        return Error("No student found with Id '{id}'");

    Course course = courseRepository.getByName(dto.getCourse());
    if (course == null)
        return Error("Course is incorrect: '{dto.getCourse()}'");

    boolean success = Enum.tryParse(dto.getGrade());
    if (!success)
        return Error("Grade is incorrect: '{dto.getGrade()}'");

    Enrollment enrollment = student.getEnrollment(enrollmentNumber);
    if (enrollment == null)
        return Error("No enrollment found with number '{enrollmentNumber}'");

    enrollment.update(course, grade);
    unitOfWork.commit();
}
public class StudentTransferDto {
    private String course;
    private String grade;
}

StudentEnrollmentDtoStudentTransferDto 的結構恰好相同,但不應該為兩個 endpoint 共用同一個 DTO。共用 DTO 會在 endpoint 之間引入耦合——當其中一個操作需要變更參數時,另一個也會被迫跟著改變。這裡沒有違反 Don’t Repeat Yourself 原則,因為這兩個 class 只是恰好結構相同,並不代表領域知識的重複。

Disenroll(退課)#

Disenroll endpoint 需要一個退課原因的 comment:

@PostMapping("{id}/enrollments/{enrollmentNumber}/deletion")
public void disEnroll(@PathParam("id") long id,
        @PathParam("enrollmentNumber") int enrollmentNumber,
        @RequestBody StudentDisEnrollmentDto dto) {
    Student student = studentRepository.getById(id);
    if (student == null)
        return Error("No student found with Id '{id}'");

    if (StringUtil.isNullOrWhiteSpace(dto.getComment()))
        return Error("Disenrollment comment is required");

    Enrollment enrollment = student.getEnrollment(enrollmentNumber);
    if (enrollment == null)
        return Error("No enrollment found with number '{enrollmentNumber}'");

    student.removeEnrollment(enrollment, dto.getComment());
    unitOfWork.commit();
}
public class StudentDisEnrollmentDto {
    private String comment;
}

修正 Domain Model 的封裝問題#

在初版的 Disenroll 實作中,controller 需要呼叫兩個 domain model 的方法:

student.removeEnrollment(enrollment);
student.addDisEnrollmentComment(enrollment, dto.getComment());

這是一個 code smell——domain model 的封裝性不足,洩漏了內部實作細節。從領域角度看,移除選課和新增退課原因這兩個操作缺一不可,應該合併為單一方法:

// Student.java
public void removeEnrollment(Enrollment enrollment, String comment) {
    enrollments.remove(enrollment);
    Disenrollment disenrollment = new Disenrollment(
        enrollment.getStudent(), enrollment.getCourse(), comment);
    disenrollments.add(disenrollment);
}

修正後在 controller 中只需呼叫一次:

student.removeEnrollment(enrollment, dto.getComment());

Edit Personal Info(編輯個人資訊)#

最後一項功能是編輯學生的個人資訊:

@PutMapping("{id}")
public void editPersonalInfo(@PathParam("id") long id,
        @RequestBody StudentPersonalInfoDto dto) {
    Student student = studentRepository.getById(id);
    if (student == null)
        return Error("No student found with Id '{id}'");

    student.setName(dto.getName());
    student.setEmail(dto.getEmail());
    unitOfWork.commit();
}
public class StudentPersonalInfoDto {
    private String name;
    private String email;
}

完成以上四個 method 的提取後,原本的 update API endpoint 和 hasEnrollmentChanged 輔助方法都可以完全移除。

Recap:拆解 Update Method#

將 student update method 拆分為四個獨立的 task-based method 後:

  • Editing personal information — 編輯個人資訊
  • Enrolling into a new course — 選課
  • Transferring to another course — 轉課
  • Disenrolling from a course — 退課

每個方法都遵守 Single Responsibility Principle,只做一件事。好處包括:

  • Cyclomatic complexity 降低:新方法中的縮排只與 input validation 有關,結構清晰
  • 一致的模式:每個方法都先做驗證、再委派給 domain model、最後 commit transaction
  • DTO 精簡化:從一個龐大的、充滿「holes」(部分欄位不使用)的 StudentDto,變成多個只包含必要欄位的小型 DTO

避免使用充滿「holes」的 DTO。所謂 holes 是指在某些情境下不會被使用的欄位。這是 CRUD-based interface 的強烈信號——因為試圖將所有修改塞進單一的 update method,DTO 被迫包含所有可能的欄位。

Task-based User Interface#

重構 API 後,UI 也隨之改變。原本的「Update Student」按鈕改名為「Edit Personal Info」,點選後只顯示 name 和 email 兩個欄位的簡潔視窗。

其餘功能以情境相關按鈕(context-dependent buttons)呈現在資料表格中:

  • 若學生尚未選滿課程,顯示 Enroll 按鈕
  • 已選課程旁顯示 TransferDisenroll 按鈕
  • 每個操作的對話框只要求完成該任務所需的最少資訊

例如,Disenroll 對話框只需要輸入一個 comment,因為退課的課程已經由按鈕的位置隱含得知。這種自然的事件流程讓使用者體驗更加直觀。

相較之下,舊的 CRUD-based UI 將四種操作混在一個大畫面上,使用者必須自行判斷每個控制項的用途,例如選擇空字串代表退課——這完全不直觀。

處理 Create 和 Delete Methods#

拆解完 Update method 後,還需要檢視 Create 和 Delete endpoint。

Create 方法的處理#

Create method 同時建立學生和選課,但這兩個操作放在同一個畫面是合理的,因為使用者通常會連續執行。不過有兩個問題需要修正:

1. 使用專屬的 DTO

原本的 Create endpoint 使用與 grid 顯示相同的 StudentDto,其中包含了建立學生時不需要的欄位(如 id、credits)。應建立專屬的 NewStudentDto

public class NewStudentDto {
    public String name;
    public String email;
    public String course1;
    public String course1Grade;
    public String course2;
    public String course2Grade;
}

2. 使用領域語言命名

以 CRUD 操作命名 endpoint 是 CRUD-based thinking 的信號。應詢問領域專家這些操作的實際名稱:

  • Create student –> Register student
  • Delete student –> Unregister student
@PostMapping()
public void register(@RequestBody NewStudentDto dto) {
    Student student = new Student(dto.getName(), dto.getEmail());
    // ... enroll logic ...
    studentRepository.save(student);
    unitOfWork.commit();
}

@DeleteMapping("{id}")
public void unregister(long id) {
    Student student = studentRepository.getById(id);
    if (student == null)
        return Error("No student found for Id {id}");
    studentRepository.delete(student);
    unitOfWork.commit();
}

清理顯示用的 DTO#

StudentDto 現在只用於 grid 顯示,可以移除用於修改的多餘欄位(如 disenrollment comments):

public class StudentDto {
    public long id;
    public String name;
    public String email;
    public String course1;
    public String course1Grade;
    public int course1Credits;
    public String course2;
    public String course2Grade;
    public int course2Credits;
}

至此,API 和 UI 都已完全轉為 task-based interface。

Task-based Interface 與 CQRS 的關係#

  • Task-based interface 不是 CQRS 的前提條件——可以獨立使用
  • 但 CRUD-based thinking 的問題通常與 CQRS 所解決的問題相伴出現
  • 如果應用程式不夠複雜,或不需要長期維護演進,CRUD-based interface 也可以接受,類似 Anemic Domain Model 的取捨

小結#

  • CRUD-based thinking 是將所有操作強行歸類為 Create、Read、Update、Delete 的思維模式,CRUD-based interface 是其結果,同時影響 API 和 UI
  • CRUD-based interface 有三大問題:複雜度不受控增長缺乏 Ubiquitous Language損害使用者體驗
  • Task-based interface 透過辨識使用者可執行的每項任務,為每項任務建立獨立的 API endpoint 和 UI 視窗
  • 拆解巨大的 update method 為四個獨立方法後,cyclomatic complexity 降低、程式碼更易維護
  • 每個 task 應有專屬的 DTO,避免充滿「holes」的大型 DTO
  • 使用領域語言命名 endpoint(如 Register/Unregister 而非 Create/Delete)
  • Task-based interface 與 CQRS 可以獨立存在,但兩者經常相輔相成