外部サービスの抽象化 Link to heading

最近のシステムはその複雑さの上昇に伴い、様々な外部サービスと連携する機会が多い。

その際、ドメインとして外部サービスをどう取り扱うといいのか、という個人的なプラクティスを過去の失敗例も含めて記事にしようと思う。

過去の失敗 Link to heading

昔私が関わっていた仮想通貨の販売所の価格判定ロジックを例に挙げてみる。

自社の販売所における仮想通貨の価格を決定する仕組みを作っていた。

複数の外部取引所のAPIから価格情報を取り出してそれらの値を使って自社の販売価格を計算をする必要がある。

販売所の価格の構造体は以下のようにドメイン層に定義していた。

type Tick struct {
	Amount int
	Market Currency
	Base   Currency
}

また、それらのTick を取り出すために各取引所のサービスごとに以下のようにinterfaceを定義していた。

type FooService interface {
	GetTick() (*Tick, error)
}

type BarService interface {
	GetTick() (*Tick, error)
}

FooやBarは取引所の名前だと想定してください。

これらもドメイン層に定義している。

また、上記interfaceを満たす実装はインフラストラクチャ層等で定義してDIするようにしている。

さて、勘の良い方はもうお気づきかもしれませんが上記interfaceの定義が失敗になっている。

一見、 Tick を取り出せるinterfaceをサービス分定義したことで問題なく動くように見える。

しかし、 GetTick からいきなり Tick 取り出そうとしているため、「外部サービスの値を使って Tick を計算する」というロジックがインフラストラクチャ層に流出するという問題が発生してしまった。

具体的な例をあげます。

取引所Fooは以下のようなResponseを返すAPIを提供していた。

{
  "currentTick": "1000000",
  "market": "btc-jpy"
}

上記の構造を Tick に割り当てるのは難しくない。

currentTick をそのまま Amount に使い、 market の文字列を定義を分解して MarketBase に割り当てれば良さそう。

しかし取引所Barは以下のような取引の板情報だけしか返さないAPIの設計になっていた。

{
  "asks": [
    {
      "rate": 120000,
      "amount": 0.1
    },
    {
      "rate": 110000,
      "amount": 0.22
    }
  ],
  "bids": [
    {
      "rate": 99000,
      "amount": 0.6
    },
    {
      "rate": 100000,
      "amount": 0.18
    }
  ]
}

ここから価格を決定するには asksbids の情報を利用して決定する必要がある。

しかし、上記の BarService interfaceに合わせて実装するとその価格を決定するロジックをどこに書くかが問題になる。

板情報から加重平均で価格決定をしたりする場合は完全にドメインロジックだが、interfaceに合わせようとするとそれをインフラストラクチャ層に書かなくてはいけなくなり、ドメイン知識が流出してしまう。

結果、ドメイン層のテストではビジネスロジックが正しいのかを担保するのかが難しくなり、インフラストラクチャ層でもドメインのロジックを気にしながらテストを書くことにしまった。

どうすればよかったか Link to heading

以下のようにFoo取引所とBar取引所が提供している構造をそのままドメイン層に定義し、それらを取得できるinterfaceを定義する。

type FooService interface {
	GetFooTick() (*FooTick, error)
}

type FooTick struct {
	CurrentTick string
	Market      string
}

func (t *FooTick) GetTick() (*Tick, error) {
	// Tick計算ロジック
}

type BarService interface {
	GetBarTick() (*BarTick, error)
}

type BarTick struct {
	Asks []Order
	Bid  []Order
}
type Order struct {
	Rate   float64
	Amount float64
}

func (t *BarTick) GetTick() (*Tick, error) {
	// Tick計算ロジック
}

type GetTicker interface {
	GetTick() (*Tick, error)
}

また、FooTickやBarTickがGetTickを実装することで、その計算ロジックをドメイン層に寄せてオンメモリのテストを書くことが可能にしている。

失敗から得たこと Link to heading

結局の所「ビジネスの要件(ドメイン)をコードに落とし込む」というDDDの基本中の基本を抑えましょうということだった。

ですが、エンジニアは抽象と具象の間を行き来する必要もあるため、意外とハマりがちな落とし穴かなとも思う(普段DBの抽象化をしているので余計に)。

例えば、ビジネス上「クラウドストレージにファイルを保存する」ということを表現する必要があれば以下のようなinterfaceを定義する人は多い。

type CloudStorageService interface {
    Save(*os.File) error
}

上記のように定義しておけば、S3でもGCSでもクラウドストレージは技術者の自由にできる。

しかし、ビジネス上「S3に保存をする」ということが非常に重要な場合、上記の定義では十分ではなく S3Service のような定義をする必要が出てくる。

「S3に保存をする」だけなら問題ないが、ビジネスの展開として「S3とGCSのどちらかを選べるようにする」となった時点で、上記のinterfaceではビジネス要件の表現がコード上で難しくなっていく可能性がある。

自分の失敗に関しても全く同じで、ビジネスの要件として「取引所Fooと取引所Barから価格を決定する」というものだったにも関わらず、それらを表現していなかったのが大きな失敗だった。

これは当時の私が「各取引所からTickを取り出す」という言葉から外と中を勝手に切り分けてinterfaceを定義し、ドメインの境界を勘違いしてしまったのが原因。

外部サービスとシステムの連携はビジネス要件として決まっていることが多いと思う。

その場合それらの単語を勝手に抽象化せず、きちんとドメイン層に必要な情報を定義していくことでビジネスサイドとの認識差を減らすことができ、DDDとしての旨味を大きく得られるのではないかなと思った。