---
# System prepended metadata

title: 2023 年 07 月 - 如何判斷物件需要拆分

---

> 當實作時發現某個物件可以同時符合兩個不同的情境，只需要做出一些調整，我們應該修改當下的物件，還是進行拆分呢？

## SRP

```ruby=
# 單一（Render - 算繪）功能
class Rectangle
    def render; end
end

class Ellipse
    def render; end
end

# --> 預期，只能做 "render"

# 單一（Render - 算繪）職責
class Rectangle
    def render; end
    
    # 其他能力，但是跟算繪相關
    def color; end
end

class Ellipse
    def render; end
    
    def color; end
end
```

## Interface

### Design by Contract

* API == Contract
    * Define Interface from Contract
    * JSON API
        * Schema
        * ...
* gRPC vs RESTful API
* MVC Framework
    * Interface / Contract
        * Model & Controller 如何互動
        * Controller & View 如何互動
    * Rails -> 慣例優於設定 --> 也是一種約定

### 實作

> Based on Sample

```ruby=
class CourseHoursCalculator
  # ...
  def remaining_hours(course, user = single_user)
    # used_hours -> used_hours_data -> DB
    # Method call chains --> 描述如何做事
    # --> call method --> 本身就是一種 API Call
    #                 --> Public Method 就是物件對外的 API
    (initial_hours(course, user) - used_hours(course, user)).round(2)
  end
    
  # ...
  private
  
  def used_hours(course, user)
    used_hours_data[[user.id, course.id]].to_i
  end
    
  def used_hours_data
    @used_hours_data ||= UsedCourse.where(user_id:, year:).group('used_courses.user_id', 'used_courses.course_id').sum('used_courses.hours')
  end
end
```

> TypeScript 對 Interface 的看法

```ts=
// 規格 -(形成)-> 約定
interface Counter {
    count: number
}

// 實作細節
class VisitorCounter {
    public count: number = 0
}

const SimpleCounter: Counter = { count: 0 }
const ClassCounter: Counter = new VisitorCounter()
```

```ts=
// TypeScript 對「資料結構」的看法
interface Counter {
    count: number
}

// 等同於（定義資料結構）
type Counter = {
    count: number
}
```

```ts=
// User Story 描述行為 ==> 訪客拜訪次數統計
// 行為 -> 規格（需求） ==> 可以計算次數（遞增）
// 規格 -> 約定的行為  ==> Object has method "increment"
interface Counter {
    increment(amount: number): number
}

// 實現約定的行為 -> 實作
class VisitorCounter implements Counter {
    // Compiler 要求實現方法
    increment(amount: number): number {
        return this.count += amount
    }
}
```

```ts=
// 常態 -> 把 interface 的概念隱藏或忽視
class VisitorCounter {
    increment(amount: number): number {
        return this.count += amount
    }
}
```


```ruby=
# Duck Typing

# implements "Counter" 
class VisitorCounter
    def increment(amount)
        self.count += amount
    end
end

# 使用方
def open(counter)
    # counter 有 "increment" 方法就可以 -> Duck Typing
    counter.increment(1)
end
```

```ruby=
# RBS 定義介面
interface Counter
  def increment: (Integer) -> Integer
end

# 使用方
def open: (Counter) -> void
```

> 延伸：跟測試的關係

## Clean Architecture

### Input Boundary / Output Boundary

```ruby=
# Controller
def index
    # Input Data -> params
    # Input Boundary -> ActionController::Parameters（不明確）
    # 對 Rails 來說，Input Boundary (interface) 是一種 Hash-like 的物件（實作跟 Hash 一樣的 Interface）
    
    h = {} # Ruby Hash
    h[:id] # Hash 的 [] 方法
    
    params[:id] # ActionController::Parameters 定義 [] 方法，實作 Hash 介面
    
    # ...
    
    # Output Data -> instance variables
    # Output Boundary -> render
end
```

```ts=
// 某框架
class VisitorController {
    // Input Boundary -> index() -> 約定了 VisitorIndexInput 類型資料 
    index(data: VisitorIndexInput): void {
        // ...
    }
}
```

### Layered Architecture

```go=
// implements http.HandlerFunc
// Presenter Layer
func (ctrl *VisitorController) IndexHandler(w http.ResponseWriter, r *http.Request) {
    // http.ResponseWriter -> Output Boundary --> Layer Presenter (RESTful/gRPC)
    // *http.Request -> Input Boundary
    // -> http === interface
    
    // Input Boundary -> increment()
    // Output Boundary -> res (pointer)
    res := ctrl.countUseCase.increment(1)
    
    // Encoder ==> Presenter
    // json data ==> View Model
    encoder := json.NewEncoder()
    // View -> JSON
    encoder.Encode(w, res)
}
```

## Domain-Driven Design

| | 狀態(instance variable) | 行為 (methods) |
|--|----|------|
|Entity/Aggregate | Yes | Yes |
|Service | No | Yes |

> Data Transfer Object -> 有資料沒有行為（資料 != 狀態）
>   DTO --> Hash / JSON
> 狀態 -> 有 Context 的 Data

## 分析

> 分析 Sample Code

* Input
* UseCase -> 要拆分的部分
    * 單一使用者餘剩時數
    * 多位使用者餘剩時數
* Ouput

### Use Case

```ruby=
# 單一使用者 Use Case
class CourseHoursController
    def index
        # 準備資料
        @available = AvailableCourse.where(user_id:, year:)
        @used = UsedCourse.where(user_id:, year:)
        @courses = Course.all
        @calculator = CourseHoursCalculator.new
        
        # 進行計算
        # Domain Model
        @calculator
            .remaining_hours(@courses, @available, @used)
            # To View
            .as_json(only: %i[remaining_hours]) #...
    end
end

# 所有使用者 Use Case
class Admin::CourseHoursController
    def index
        # 準備資料
        @user_ids = User.where('extract(year from join_date) <= ?', @year)
        # User.before_year(@year)
        # Concern --> included by User
        @courses = Course.all
        @availables = AvailableCourse.where(user_id:, year:).group('user_id', 'course_id')
        @usedes = UsedCourse.where(user_id:, year:).group('used_courses.user_id', 'used_courses.course_id')
        @calculator = CourseHoursCalculator.new
        
        # 進行計算
        @user_ids.map do |user_id|
            {
                user_id:,
                courses: @calculator.remaining_hours(@courses, @available[user_id], @used[user_id])
            
        end
    end
end
```

```ruby=
# Service Object
class CourseHoursCalculator
    def remaining_hours(courses, available, used)
        courses.map do |course|
          {
              id: course.id, 
              remaining_hours: remaining_hours(available[course.id], used[course.id]) 
              extra_hours: ....
              # ...
          }
        end
    end
    
    private
    
    # ...
end
```



## Sample

這是一個賣線上課程的網站
計算哪些線上課程使用者買了多少時數，用了多少時數，還剩下多少時數
1. 本來只寫了單一使用者的 user_remaining_hours
2. 後來才加上所有當年度(含之前)加入的使用者 manage_statistics_hours
```ruby=
class CourseHoursCalculator
  def initialize(user: nil, year: nil)
    @single_user = user
    @year = year || Time.zone.today.year
    @users = User.where('extract(year from join_date) <= ?', @year)
    @user_id = user&.id || @users.select(:id)
  end
    
  def user_remaining_hours
    Course.all.map do |course|
      { id: course.id, remaining_hours: remaining_hours(course) }
    end
  end
 
  def remaining_hours?(course, user = single_user)
    initial_hours(course, user).positive?
  end
  
  def remaining_hours(course, user = single_user)
    (initial_hours(course, user) - used_hours(course, user)).round(2)
  end
 
  def manage_statistics_hours
    users.map do |user|
      {
        user_id: user.id,
        courses: course_hours(user)
      }
    end
  end
  
  private
  
  def used_hours_data
    @used_hours_data ||= UsedCourse.where(user_id:, year:).group('used_courses.user_id', 'used_courses.course_id').sum('used_courses.hours')
  end
  
  def initial_hours_data
    @initial_hours_data ||= AvailableCourse.where(user_id:, year:).group('user_id', 'course_id').sum('initial_hours + extra_hours')
  end
  
  def extra_hours_data
    @extra_hours_data ||= AvailableCourse.where(user_id:, year:).group('user_id', 'course_id').sum('extra_hours')
  end
  
  def used_hours(course, user)
    used_hours_data[[user.id, course.id]].to_i
  end

  def initial_hours(cource, user)
    initial_hours_data[[user.id, cource.id]] || 0
  end
  
  def course_hours(user)
    Course.all.map do |course|
      {
        id: course.id,
        extra_hours: extra_hours_date[[user.id, course.id]] || 0,
        initial_hours: remaining_hours?(course, user) ? initial_hours(course, user) : 0,
        used_hours: used_hours(course, user),
        remaining_hours: remaining_hours?(course, user) ? remaining_hours(course, user) : 0
      }
    end
  end
end
```

## Reference

* [Open-Closed Principle](https://www.youtube.com/watch?v=aSX76noRP2w)