💨

Modular Monolith 化

2024/02/28に公開

はじめに

CastingONE でバックエンドエンジニアをやっている城崎です。

この記事では CastingONE の一部のコードを Modular Monolith へ移行した話を取り扱います。

CastingONE では複数のサービスを一つのリポジトリ(モノレポ)で管理しています。
しかし、複数のサービスに全く同じコードが存在している状態であり、複数のファイルに同じ修正をすることがありました。
この状態を解消し、メンテナンスしやすくするために、共通する機能をモジュール化することにしました。

今回は扱わない話

  • モノレポについて
  • マイクロサービスについて
  • レイヤードアーキテクチャについて

解消したい課題

先に述べた通り、CastingONE のコードには複数のサービスで同じコードが存在している状態でした。
具体的に、私たちのプロダクトでは企業が個人に向けて Email や LINE でメッセージを送信する機能を提供しています。
その送信対象について特定の県や市などのエリアに住んでいる人や、登録してから 1 週間以内の人などに絞って送信できるようにしています。
私たちはこの機能をセグメントと呼んでいます。
セグメントにどの人を含めるかは B 向けのサービス内で設定することができ、実際に抽出を行うのは segmenter と呼んでいるサービスが定期的に行なっています。
リクエストを受けてから抽出を行うとレスポンスに時間がかかるため、最新のデータではない可能性はあるがおおよその対象者を返すようにしています。
しかし、実際にメッセージを送信するのは messenger と呼んでいる別のサービスで、
こちらは segmenter が抽出したデータではなく、最新のデータから抽出した対象者を使用する実装になっています。
このセグメントから実際に送信対象者を取得するコードが segmenter と messenger の両方に存在していました。
そのため、セグメントに新しい条件を追加したくなった時には、両方のコードを修正する必要がありました。

今回の取り組み

重複しているコードをモジュールとして切り出し、それぞれのサービスがこのモジュールを使用するようにしました。
具体的にどのような実装したかと、設計思想では実装する上で悩んだことと、何を決定したかを記載します。

実装

バックエンドのコードは Go で書いており、ディレクトリ構成は以下のようにしました。modules が今回追加したものです。

.
├── go.mod
├── go.sum
├── messenger
│   ├── Dockerfile
│   ├── Makefile
│   ├── README.md
│   ├── application
│   ├── config
│   ├── coverage
│   ├── domain
│   ├── infrastructure
│   ├── interface
│   ├── main.go
│   ├── templates
│   └── testhelper.js
├── modules
│   ├── Makefile
│   └── segment
│       ├── Makefile
│       ├── internal
│       │   ├── application
│       │   ├── domain
│       │   └── infrastructure
│       ├── mock_module.go
│       ├── model
│       │   └── model.go
│       └── module.go
├── segmenter
│   ├── Dockerfile
│   ├── Makefile
│   ├── README.md
│   ├── application
│   ├── config
│   ├── coverage
│   ├── domain
│   ├── infrastructure
│   ├── interface
│   └── main.go

modules/segment/module.go に外部から利用できるインターフェースが定義されています。
modules/segment/model/model.go にインターフェースが使用する引数と返り値の構造体が定義されています。
module.go には func NewSegmentModule() SegmentModular 関数も定義されており、
messenger や segmenter はこの関数を呼び出し、インターフェースに定義された関数を呼び出すことができます。
実際のコードの一部を抜粋すると以下のようになっています。

func NewSegmentModule() SegmentModular {
	return &segmentModule{
		// DI
	}
}

type SegmentModular interface {
	GetStaffSegments(ctx context.Context, tenantID int) ([]model.StaffSegment, error)
	StaffIDsBySegment(ctx context.Context, tenantID int, segmentID int64) ([]int, error)
    ...
}

実際の処理の内容については modules/segment/internal ディレクトリ内のファイルに記述されており、
module.go には internal 内の関数を呼び出すだけになっています。

設計思想

移行を実施する中で大きな議論は 2 つありました。

  • 1 つ目はモジュールの単位をどうするか
  • 2 つ目はアクセス権をどこで管理するか

1 つ目のモジュールの単位については 1 つのモジュールを 1 つのマイクロサービスに相当するものとしました。
これは、組織が大きくなり、別チームがモジュールをメンテナンスをするときにマイクロサービスとして切り出しやすくなるメリットがあると思います。
ただし、CastingONE ではまだマイクロサービス化する予定はないので、各サービスからこのモジュールをインポートして使用します。
もし、モジュールをマイクロサービスにする場合には、module.go に記述されているインターフェースがサービスのエンドポイントと一致します。

2 つ目のアクセス権について、ここでは「どのサービスがモジュール内のどの関数を呼び出して良いのか」を指します。
例えば、B 向けのサービスと C 向けのサービスで同じオブジェクトにアクセスする場合に、C 向けサービスからは閲覧のみを許可し、B 向けサービスからは更新も許可したいとします。
この場合、更新用の関数はモジュール内に定義されているのですが、C 向けサービスからは呼び出せない方が間違いを防げそうです。
ですが、今回はモジュール内に実装されている関数を呼び出せないようにする仕組みは実装しませんでした。
これは、モジュールとサービスのメンテナーが同じであるため、過剰な実装をしなくても誤って呼び出す状況はないだろうと考えたためです。
もし、ソースコードをメンテナンスするチームが分かれるときは、この境界を整備することとしました。

これから

徐々に機能をモジュールへと移行し、各サービスの実装は薄いものになるようにしていきたいと考えています。
しかし、機能がたくさんあるため、できる部分からモジュール化していく必要があります。

おわりに

この記事では、複数のサービスで同じコードが記述されている状態を解消するために、
共通する機能をモジュール化することで、メンテナンスしやすくすることを目的として取り組んだことを紹介しました。
もし、この記事が誰かの参考になれば幸いです。

参考

https://r-kaga.com/blog/what-is-modular-monolith

Discussion