🚀

interface、structで書くか、functionで書くか

2025/01/12に公開

始めに

この正月に、関数型ドメインモデリングという本を読みました。良書でした。
https://amzn.asia/d/4NlwXFg

そこで、今までオブジェクトで書いていたコードを関数としてかけないか?という思いつきでこの記事を書いた結果、なんだか関数型とは関係ない感じの記事になってしまいました。ご容赦ください。

ベースとなるサンプルコード

まずはオブジェクト指向でよく使う形のサンプルを用意しました。タスク管理のモデルです。簡単のため、エラーなどはあまり返さないようにしています。

domain/user.go
package domain

import "fmt"

type status string

const (
	StatusPending status = "pending"
	StatusDoing   status = "doing"
	StatusDone    status = "done"
)

func NewStatus(s string) (status, error) {
	switch s {
	case "pending":
		return StatusPending, nil
	case "doing":
		return StatusDoing, nil
	case "done":
		return StatusDone, nil
	default:
		return "", fmt.Errorf("invalid status")
	}
}

type Task struct {
	ID     int
	Name   string
	Status status
}

func NewTask(id int, name string, status status) *Task {
	return &Task{
		ID:     id,
		Name:   name,
		Status: status,
	}
}

func (t *Task) Do() {
	t.Status = StatusDone
}

このドメインモデルを使うUsecaseのコードは、よくあるオブジェクト指向では以下のようになると思います。

usecase/task_do.go
package usecase

import (
	"context"
	"fmt"
	"playground/domain"
)

type TaskDoUsecase interface {
	Run(ctx context.Context, task *domain.Task) (*domain.Task, error)
}

type taskDoUsecase struct {
}

var _ TaskDoUsecase = (*taskDoUsecase)(nil)

func NewTaskDoUsecase() *taskDoUsecase {
	return &taskDoUsecase{}
}

func (u *taskDoUsecase) Run(ctx context.Context, task *domain.Task) (*domain.Task, error) {
	fmt.Println("Instance Usecase")
	task.Do()
	return task, nil
}

Usecaseオブジェクトのコンストラクタが存在し、唯一のRunメソッドによって実行がされる形です。パッケージ内にインターフェースを持っています。

これを使うhandlerはオブジェクト指向的には以下のようになります。

handler/task_do.go
package handler

import (
	"context"
	"fmt"
	"playground/domain"
	"playground/usecase"
)

type taskDTO struct {
	ID     int
	Name   string
	Status string
}

type TaskDoHandlerWithInstance interface {
	Handle(context.Context, taskDTO) (taskDTO, error)
}

var _ TaskDoHandlerWithInstance = (*taskDoHandlerWithInstance)(nil)

type taskDoHandlerWithInstance struct {
	taskDoUsecase usecase.TaskDoUsecase
}

func NewTaskDoHandlerWithInstance(
	taskDoUsecase usecase.TaskDoUsecase,
) *taskDoHandlerWithInstance {
	return &taskDoHandlerWithInstance{
		taskDoUsecase: taskDoUsecase,
	}
}

func (u *taskDoHandlerWithInstance) Handle(ctx context.Context, inputTask taskDTO) (taskDTO, error) {
	fmt.Println("Instance Handler")
	status, err := domain.NewStatus(inputTask.Status)
	if err != nil {
		return taskDTO{}, err
	}

	task := domain.NewTask(inputTask.ID, inputTask.Name, status)

	task, err = u.taskDoUsecase.Run(ctx, task)
	if err != nil {
		return taskDTO{}, err
	}

	return taskDTO{
		ID:     task.ID,
		Name:   task.Name,
		Status: string(task.Status),
	}, nil
}

handlerのフィールドにusecaseを持ち、内部ではRunメソッドを実行するだけです。
そしてこれらを実体化するDIの層では以下のようになります。

	taskDoUsecase := usecase.NewTaskDoUsecase()
	instanceHandler := handler.NewTaskDoHandlerWithInstance(
		taskDoUsecase,
	)

実体化したusecaseをhandlerのコンストラクタに渡し、handlerのメソッドによって一連の流れが実行されます。

一部関数に変えてみる

以上のusecase部分を関数にしてみます。

usecase/task_do.go
type TaskDoFunc func(ctx context.Context, task *domain.Task) (*domain.Task, error)

var _ TaskDoFunc = TaskDo

func TaskDo(ctx context.Context, task *domain.Task) (*domain.Task, error) {
	fmt.Println("Function Usecase")
	task.Do()
	return task, nil
}

依存方向を安定させるため、定義した関数の型に依存する形で具象を書きました。インターフェースと同じように見えますね。

コンストラクタや、structの記述がなくなった分スリムになりました。そしてこれを使う側のコードは以下のようになります。

handler/task_do.go
type TaskDoHandlerWithFunc interface {
	Handle(ctx context.Context, inputTask taskDTO) (taskDTO, error)
}

var _ TaskDoHandlerWithFunc = (*taskDoHandlerWithFunc)(nil)

type taskDoHandlerWithFunc struct {
	taskDoFunc usecase.TaskDoFunc
}

func NewTaskDoHandlerWithFunc(
	taskDoFunc usecase.TaskDoFunc,
) *taskDoHandlerWithFunc {
	return &taskDoHandlerWithFunc{
		taskDoFunc: taskDoFunc,
	}
}

func (u *taskDoHandlerWithFunc) Handle(ctx context.Context, inputTask taskDTO) (taskDTO, error) {
	fmt.Println("Function Handler")
	status, err := domain.NewStatus(inputTask.Status)
	if err != nil {
		return taskDTO{}, err
	}

	task := domain.NewTask(inputTask.ID, inputTask.Name, status)

	task, err = u.taskDoFunc(ctx, task)
	if err != nil {
		return taskDTO{}, err
	}

	return taskDTO{
		ID:     task.ID,
		Name:   task.Name,
		Status: string(task.Status),
	}, nil
}

これはほぼ記述が変わりませんが、インターフェースをフィールドとして持っていた部分が、関数をオブジェクトとして持っています。

DIの層を見てみましょう

	functionHandler := handler.NewTaskDoHandlerWithFunc(
		usecase.TaskDo,
	)
	functionHandler.Handle(ctx, inputTask)

usecaseを実体化する必要はなく、関数オブジェクトをそのまま利用し、handlerのコンストラクタに渡せています。

テストを書く際も、モックを利用することなく使い捨ての関数を入れればいいのでテストがやりやすいです。

フィールドに別のオブジェクトを持つ場合を考える

リポジトリパターンなど、他にDIしなければならないとき、ありますよね。

domain/repository/task.go
package repository

import "playground/domain"

type TaskRepository interface {
	Save(task *domain.Task) error
}
infra/repository/task.go
package repository

import (
	"database/sql"
	"playground/domain"
	domainRepo "playground/domain/repository"
)

var _ domainRepo.TaskRepository = (*taskRepository)(nil)

type taskRepository struct {
	db *sql.DB
}

func NewTaskRepository(db *sql.DB) *taskRepository {
	return &taskRepository{
		db: db,
	}
}

func (r *taskRepository) Save(task *domain.Task) error {
	_, err := r.db.Exec("INSERT INTO task (id, name, status) VALUES (?, ?, ?)", task.ID, task.Name, task.Status)
	if err != nil {
		return err
	}

	return nil
}

こんな感じでリポジトリを定義してみました。普段の感じでこのリポジトリを利用、DIしていくと以下のようになります。

package usecase

import (
	"context"
	"fmt"
	"playground/domain"
	"playground/domain/repository" // 追加
)

type TaskDoUsecase interface {
	Run(ctx context.Context, task *domain.Task) (*domain.Task, error)
}

type taskDoUsecase struct {
	taskRepo repository.TaskRepository // 追加
}

var _ TaskDoUsecase = (*taskDoUsecase)(nil)

func NewTaskDoUsecase(
	taskRepo repository.TaskRepository, // 追加
) *taskDoUsecase {
	return &taskDoUsecase{
		taskRepo: taskRepo, // 追加
	}
}

func (u *taskDoUsecase) Run(ctx context.Context, task *domain.Task) (*domain.Task, error) {
	fmt.Println("Instance Usecase")
	task.Do()
	u.taskRepo.Save(task) // 追加
	return task, nil
}

これを関数に適用してみます。

package usecase

import (
	"context"
	"fmt"
	"playground/domain"
	"playground/domain/repository"
)

type TaskDoFunc func(ctx context.Context, task *domain.Task, taskRepo repository.TaskRepository) (*domain.Task, error)

var _ TaskDoFunc = TaskDo

func TaskDo(ctx context.Context, task *domain.Task, taskRepo repository.TaskRepository) (*domain.Task, error) {
	fmt.Println("Function Usecase")
	task.Do()
	taskRepo.Save(task)
	return task, nil
}

するとどうでしょう、関数の引数としてrepositoryが必要になってしまいました。これではクリーンアーキテクチャ的には内部の知識が漏れ出てしまっています。なので別のアプローチを考えてみます。

package usecase

import (
	"context"
	"fmt"
	"playground/domain"
	"playground/domain/repository"
)

type TaskDoFunc func(ctx context.Context, task *domain.Task) (*domain.Task, error)

func NewTaskDoFunc(taskRepo repository.TaskRepository) TaskDoFunc {
	taskDoFunc := func(ctx context.Context, task *domain.Task) (*domain.Task, error) {
		fmt.Println("Function Usecase")
		task.Do()
		taskRepo.Save(task)
		return task, nil
	}
	return taskDoFunc
}

関数のコンストラクタ関数を作ってみました。これにより、関数オブジェクトの生成時にrepositoryを渡しておけば、それが使われます。記述も簡潔になり、かなり良さそうに見えます。

しかしこれにも問題があって、テストが非常に書きづらいです。
あくまで関数オブジェクトを返す関数を外部に公開しているだけなので、テスト範囲としては正しく関数オブジェクトを生成できているか? くらいしかチェックができません。これをしっかりテストしようとすると、プライベート関数に出力部分を切り出し、ホワイトボックステストを書く方法はありますが、ブラックボックステストを書きたい場合は不可能です。関数の責務を、正しく実行可能な関数を返す、というところまで増やすことでテスト自体は可能ですが、なかなか煩雑に見えることもあるでしょう。

まとめ

関数オブジェクトでやりくりするのはすでにオブジェクト指向で書かれているプロジェクトでは嬉しくないし、諦めてインターフェースとコンストラクタでやりくりしよう

Discussion