LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

イベント駆動とドメインモデルの完全性を意識したアーキテクチャ設計

こんにちは。LINEヤフー株式会社で、出前館というプロダクトのサーバーサイドエンジニアをしている古田大志です。

株式会社出前館はLINEヤフーのグループ会社です。資本業務提携を結んでいて、LINEヤフーが開発などをサポートしています。
詳しくはこちらをご参照ください。(https://corporate.demae-can.co.jp/pr/news/demaecan/line.html)(外部サイト)
今回の記事では、その出前館における開発の内容を紹介させていただきます。

出前館はデリバリーサービス事業のプロダクトで、開発においてはマイクロサービスアーキテクチャを採用しています。出前館のマイクロサービスの1つに、クーポンに関するドメインの責務を持ったコンポーネントであるクーポンサービスがあります。

クーポンサービスでは、ビジネスエンハンスに伴う「非機能要件の増大」や「仕様の複雑さの肥大化」という課題に対して、「イベント駆動のインフラアーキテクチャ」や「ドメインの凝集度や完全性を意識したアプリケーションアーキテクチャ」を採用し開発を進めてきました。本記事ではそれらの技術的工夫について紹介したいと思います。

なお、過去には出前館のマイクロサービスに関する記事や、クーポンサービスに関する記事などもあるので、そちらもぜひご参照ください。

クーポンサービスとは

クーポンサービスとは、出前館内のクーポン機能のデータなどを管理するバックエンドのシステムです。
出前館におけるクーポンとは、「1500円以上買ったら送料310円」「新規会員のみ500円off」など値引きを行う機能のことです。これらエンドユーザーに提供しているクーポンは、裏側の業務としては、出前館がマーケティング施策としてクーポンを作ったり、出店している加盟店がキャンペーンとしてクーポンを登録したりすることによって、データが作成されています。クーポンサービスでは、このさまざまなactorに対するデータフローの中心に立ち、データの管理や処理を行っています。

クーポンサービスの役割

その他にも、出前館におけるクーポンは、ビジネス要件を達成するためにさまざまな属性を持っています。

例を挙げると

  • 使えるユーザーの区分を限定する「初回ユーザー専用 / 誰でも利用可能」
  • 使える加盟店の区分を限定する「店舗指定 / どの店舗でも利用可能」
  • 値引きの対象を指定する「商品金額値引き / 送料金額値引き」
  • 誰がお金を出したかの区分を管理する「加盟店原資 / 出前館原資」

など、さまざまなパラメータを設定することができます。また単純なenumなどでは表現できないようなビジネスロジックもたくさんあります。

これらの表現豊かな仕様が、開発における実装の複雑さを上げています。

イベント駆動アーキテクチャについて

出前館のクーポンドメインにおける仕様の複雑さに、「クーポンの枚数の変更」や「注文時のクーポン利用」など、エンドユーザーの行動がトリガーとなってクーポンの状態に変化が起きることが挙げられます。出前館のアクティブユーザーは、数百万のオーダーに上っていて、それに付随してクーポンに関するデータの更新系/参照系に関する非機能要件も大きくなっています。また、クーポンという特性上、キャンペーンや配信に伴いエンドユーザーのアクセスが一時的に増え、開発側が想定していないようなトラフィックが発生することがあります。

例えばトラフィックがコントロールできないほど大きくなり、律速しにくい場合に関して、更新系の場合は適切なトランザクション処理をできなくなるかもしれないといった懸念があります。また、アクセスの多いデータに関しては、Redisなどを用いてキャッシュするのがレスポンス高速化の方法の1つとして挙げられますが、このキャッシュするデータのライフサイクルも更新が多いものに関しては気をつける必要が出てきます。

これらのような更新系/参照系の課題を解決するために、クーポンサービスの開発ではイベント駆動のインフラアーキテクチャを採用しています。具体的には、ユーザーへクーポンを付与するという事象や、ユーザーの注文時のクーポン利用、またデータの変更によるユーザー用キャッシュデータへの反映などを全てイベントとして取り扱い、Apache Kafkaというメッセージキューを利用して非同期化して処理フローを構築しています。

一例としてクーポンを利用するユースケースでのデータフローの図を示します。

イベント駆動のデータの流れ

このように、各処理の受け渡しを、同期的なAPI処理ではなくイベント連携の形式で非同期で行うことにより、更新系の律速を出前館のシステム側が主体で行うことができ、トランザクションなどの処理が失敗しないようにしています。また、メッセージキューを使うことでデータ連携の可用性を高めることができています。

また、上記の例で挙げた図では、注文イベントをproduceするのはクーポンサービスとは別のシステムで、出前館内の他のマイクロサービスが担っています。 このように異なるチームのシステム間もイベント連携することで、consumeする側が必要な情報のみを受け取ることが可能になっているのも1つのメリットと言えます。

ドメインモデルの完全性について

クーポンサービスというマイクロサービス内では、上記で挙げたような非同期のイベントを処理するsubscriber以外に、同期的なapi処理や、定期的なbatch処理のユースケースもあります。言語は Java 17, フレームワークは Spring Boot 3.0 で実装されていて、ランタイムとしてはapi/batch/subscriberの3種類のアプリケーションをモノレポで取り扱っています。

下記は実際のディレクトリ構成の図です。

ディレクトリ構成

これらの複数のアプリケーションから、クーポンのデータを管理する1つのdatabaseに対して更新をする場合などに、データの不整合やバリデーションの漏れなどが発生しやすいという課題がありました。

複数アプリケーション間でのデータベースの共有

このような、実装における課題を解決するために、ドメインモデルと呼ばれるようなオブジェクトを定義し、そのインスタンスに対する完全性が常に担保されてからdatabaseに対するCRUDが行われるようなアプリケーションアーキテクチャを採用しています。

具体的には、まず下記のようなクーポンのアグリゲーション(集約)を表現するクラスを定義します。

public class Coupon {
     private final String id;
     private final CouponType couponType;
     ...
}

このクーポンを表すクラスをドメインモデルと呼んでいます、このクラスは api/batch/subscriber 全てのアプリケーションで共通で使うクラスです。どのプロジェクトからも依存できるように、Gradle上では別のプロジェクトとして切り出しています。

このクラスに対して、バリデーションなどをコンストラクタに実装していきます。

public Coupon(
     final String id,
     final CouponType couponType,
     ...
) {
     //バリデーションの処理
}

このように、バリデーションをコンストラクタに記述することにより、Couponというインスタンスが生成された時点で必ず業務的に正しい値となる、すなわち完全性を担保するようになっています。

ここで業務的に正しい・正しくないという状態に対して、一例を挙げます。
「1つのクーポンに対して1人のユーザーが使える上限枚数」という値 maxOneUserUseCount 、「1つのクーポンに対してユーザー全体で使えるクーポンの上限枚数」という値 maxEntireUserUseCount の2つのフィールドについて考えてみます。この値はどちらも Coupon というオブジェクトのフィールドとして実装されています。

public class Coupon {
     private final String id;
     private final CouponType couponType;
     private final Integer maxOneUserUseCount;
     private final Integer maxEntireUserUseCount; 
     ...
}

これらの値は、作りたいクーポンによってさまざまな値を取りますが、業務的な制約として常に maxEntireUserUseCount >= maxOneUserUseCount を満たします。これは一人で使える枚数が全体より多い、ということがあり得ないからです。

このような、任意のクーポンに対して制御するべきバリデーションをコンストラクタに記述することで、常にこの業務的に正しい状態を担保できます。

もちろん、この状態を満たさないクーポンのインスタンスが生成されるような時には、例外を吐いてインスタンスを生成できないようにします。

        // 全体の最大利用枚数 < 1人あたりの最大利用枚数 の場合にエラー
        if (maxEntireUseCount < maxOneUserUseCount) {
            throw new RuntimeException("maxOneUserUseCount must not be more than maxEntireUseCount")
        }

このように、データに対するバリデーションをモデルクラスのコンストラクタに一元化し、凝集性を高めています。

また、databaseに対する永続化を行うメソッドの引数に上記で定義した型定義を利用し、メソッドを一元化することでdatabaseに入りうるデータを常に上記の業務ロジックが満たされた状態を担保できます。

public void postCoupon(final Coupon coupon) {
    ...
}

クーポンサービスの実装では、上記のメソッドを通じてのみdatabaseへデータを永続化できるようにしており、またこれ以外の永続化できるメソッドはありません。Couponという型が引数になっているため、このインスタンスが生成できないと、そもそもdatabaseにデータを入れることができない仕組みになっています。このようにして、ランタイムとしてアプリケーションが複数に分かれていても、データは常に同じ spec(仕様)を保つことができます。

しかしながら、実際の業務やビジネスエンハンスでは、上記のような奇麗な実装のみでは要件を達成することができないパターンも往々にしてあります。例えば、apiからのデータ更新ではad hocなバリデーションが必要だったり、batch処理では別のチェックがあったり、などです。アプリケーション固有のロジックというのが出前館の案件ではよくあるので、そのようなさまざまなユースケースに対してもうまく処理できるように実装上の構成として usecase layerとdomain layerという2つの階層を取り入れています。

usecase layer / domain layer の概念図を示します。実際のソースコード上の実装としてはもう少しレイヤーが分かれていることもありますが、この図では割愛しています。

usecase layerとdomain layerの図

いわゆる、クーポン全体で守られるべきspecに関しては domain specとして、ランタイムを問わず共通のdomain layerでバリデーションを入れます。また、apiやbatch, subscriberなどの固有のバリデーションに関しては、usecase specとして usecase layerにバリデーションを入れます。

開発の中で、このバリデーションはgeneralなものか? ad hocなものか? をチームでよく議論し、都度 domain specで実装するか usecase specで実装するかを検討しています。最初はusecase specとして実装していたものが、他のユースケースでも必要になった結果 domain specに昇格したりすることが頻繁にあります。ドメインの見え方や在り方が日々変化し、それに合わせて実装も変化させていくことが大事かと思います。

おわりに

デリバリーサービス事業のプロダクトである出前館における、マイクロサービスの1コンポーネントとして稼働するクーポンサービスのアーキテクチャ設計について紹介しました。

この記事では大きく2つのポイントを挙げました。

インフラ面でのアーキテクチャ設計として

  • 非同期なイベント駆動でデータ連携を行い、処理を律速しコントロールすることで、可用性やパフォーマンスを高めている

アプリケーション面のアーキテクチャ設計として

  • ビジネスエンハンスによって求められる 同期的なapi・非同期的なsubscriber・バルクや定期実行のbatchなどのさまざまなアプリケーションのパターンに対して、usecase layer / domain layerを定義し、domain specでドメインモデルの完全性を担保している

これらの内容が、この記事を読んだ方の参考になれば幸いです。

採用情報

出前館ではサーバーサイドエンジニアを募集しています。
この記事を読んでもし興味を持った方がいれば、ぜひご確認ください。
採用ページ(株式会社出前館のページに遷移します):https://hrmos.co/pages/demaecan/jobs/60000013