フロントエンド✖️DDDで自社サービスの新規機能開発をした話
はじめに
はじめまして。BEENOSの家住です。
今回はフロントエンド開発でDDDを使って新機能開発をした話を共有させていただきます。
何を作ったか
自社サービス Buyee に新機能であるAIサポート機能
をフロントエンド×DDDを使って開発しました。
Buyeeとは
Buyeeとは、自社の主に海外ユーザー向けのサービスで、海外から日本のECサイトの商品の購入を容易にするため、代理でBuyeeが商品を購入し、ユーザーのもとまで配送するサービスとなっています。
いわゆる越境ECと言われるサービスです。
越境ECとは
電子商取引(EC:E-Commerce)において、国境を越えて取引を行うことを越境ECと呼びます。海外のECサイトで商品を購入して日本に取り寄せる、あるいは日本のECサイトで海外の顧客向けに商品を販売するといったことが挙げられます。
https://www.ntt.com/bizon/glossary/j-a/ekkyou-ec.html
AIサポート機能とは
ユーザーが入力したキーワードに関連するキーワードをAIが考え、その関連するキーワードで商品の検索を行う機能です。
本機能により、海外ユーザーが言語問わず特定のキーワードに関連する商品の一括検索が可能になり、ユーザーの商品への動線を増やすことが可能になりました。
例)AIサポート機能を使って三丽鸥
(日本語でサンリオ)を検索
以下の検索結果が表示される
- サンリオ
- サンリオキャラクター
- サンリオショップ
- サンリオランド
- サンリオパーティー
今回はこのAIサポート機能をフロントエンド×DDDで実現した話になります。
DDDについてよく知らない、という方は軽く概要を以下にまとめたのでご参考の程よろしくお願いします。
DDDについて
ドメイン駆動設計(DDD)とは
ドメイン駆動設計(以下 DDD)の理念は、2003年出版のエリック・エヴァンス氏の書籍、
「Domain-Driven Design: Tackling Complexity in the Heart of Software」
にて提唱されたソフトウェア設計手法です。
DDDについて別の書籍から簡単な説明を引用します。
DDDを簡単に説明すると「顧客と開発者が業務を戦略的に理解し、共通の言葉を使いながらシステムを発展させる手法」です。具体的には共通言語である「ユビキタス言語」を用いて「ドメインモデル」を構築し、それをコードとして実装します。また、大規模で密結合なシステムにならないように、「ドメイン」と「境界づけられたコンテキスト」でシステムを分割し、「コアドメイン」という最重要領域に集中して開発を行います。
引用: 「実践ドメイン駆動設計」から学ぶDDDの実装入門
ソフトウェアで解決したい部分(領域)について、開発者やその業務に詳しい人(ドメインエキスパート)を交えて話し合いを重ね、要件やビジネスルールを定義し、それを実際のソフトウェアに落とし込む開発手法です。
ドメイン
DDDにおけるドメインとはソフトウェアで解決したい対象領域の「知識」、「ビジネスルール」、「要件」のことを指します。
ECサイトを例とすると、「商品ドメイン(商品についての機能)」、「注文ドメイン(注文についての機能)」、etc...などが領域になります。
ドメインモデル
ドメインモデルとは、ドメインを表現するために、抽象化を行い不要な要素を取り除いた概念モデルになります。
ドメインモデルは以下の要素で構成されます。
- 値オブジェクト
- エンティティ
- 集約
- ドメインサービス(今回の記事では省略します)
値オブジェクト
値オブジェクトとは、何らかの値と不変条件を持つオブジェクトです。
不変条件とは、そのオブジェクトが満たすべき条件になります。
例として、メールアドレスを値オブジェクトとして定義した時、アドレスとしての値は単なる文字列ではなく、メールアドレス形式の文字列である必要があります。
値オブジェクトはその値によって同値性が識別されます。
エンティティ
エンティティとは、一意に識別され長期的に渡って存在・変化するオブジェクトです。
例として、ECサイトの会員をエンティティとして定義した時、必要な属性は以下になります。
- 会員ID
- メールアドレス
- パスワード
- 電話番号
- 住所
- etc...
エンティティは一意に識別されるために会員IDを持ちます。
会員がメールアドレスやパスワード、住所を変更したとしても会員IDが同一であればそれは「同じ会員」として扱います。
集約
集約とは、エンティティと値オブジェクトによって構成されるオブジェクトのまとまりで、トランザクション整合性を保つ責任を持ちます。
外部から集約を操作する場合は一つのエンティティ(集約ルート)を代表オブジェクトのみ参照することが可能で、いわゆるCRUDの処理は集約ルートに対して行います。
会員集約の場合は会員エンティティが集約ルートであり、集約を通して会員情報の変更や退会などのCRUDを行います。
リポジトリ
リポジトリとは、ドメインモデルの保存や・読み出しを担うオブジェクトのことです。
基本的に集約とリポジトリはセットで存在し、集約の取得や、永続化を行います。
境界づけられたコンテキスト
境界づけられたコンテキストとは、ドメインの解決する部分(領域)を明示的に定義したものになります。
ECサイトを例とすると、「商品を購入する」という機能に対して会員の決済情報など購入にまつわる情報は必要ですが、会員の名前や電話番号の情報は不要です。
このようにソフトウェアの機能ごとに必要となる情報を明確にし、文脈(Context)の単位で分割したものが境界づけられたコンテキストになります。
ユビキタス言語
ユビキタス言語とは、ドメインエキスパートや開発者を含めチーム全体で作り上げる共通言語です。
例として、「商品」という言葉は「注文コンテキスト」ではユーザーに検索・購入されるものですが、配送コンテキストの場合の商品は荷物のことを指します。
このように同じ言葉であっても境界づけられたコンテキストごとに違う意味や振る舞いを持つことがあり、それがそのままコードに落とし込まれることになるため、注意して定義する必要があります。
AIサポート機能の開発
DDDの基本知識を踏まえて本機能をフロントエンド×DDDで開発していきます。
開発環境
- Next.js
- ライブラリ
- tsyringe
- immutable
- アーキテクチャ
- DIPレイヤードアーキテクチャ
レイヤードアーキテクチャ
レイヤードアーキテクチャとは、アプリケーションを層で分割し、各層が特定の役割を担うように設計・実装するアーキテクチャです。
本プロジェクトではDIP(依存性逆転の法則)を使用したレイヤードアーキテクチャを採用しています。
各レイヤーについては後ほど説明します。
依存性逆転の法則
依存性逆転の法則とは、Robert C. Marting氏が提唱した以下の原則になります。
a. 上位のモジュールは下位のモジュールに依存してはならない。どちらのモジュールも「抽象」に依存すべきである
b. 「抽象」は実装の詳細に依存してはならない。実装の詳細が「抽象」に依存すべきである
https://atmarkit.itmedia.co.jp/fdotnet/designptn/designptn07/designptn07_01.html
ドメイン層のリポジトリをinterfaceで定義することで、ドメイン層は他レイヤーに依存しなくなります。
また、DIPを使用することでアプリケーション層に影響を与えずにインフラストラクチャ層のリポジトリを修正することが可能になります。
Next.jsで依存性逆転の法則を実現するために
DIPを実現するためには、DI(依存性注入)の機能が必要になります。
依存性注入を簡単に説明すると、あるオブジェクトが別のオブジェクトを利用する場合に、外部から必要なオブジェクトを供給する手法です。
Spring Frameworkや、laravelのようにDIコンテナの機能を持つ場合はDIが実現可能ですが、Next.jsはDIコンテナの機能を持ちません。
本プロジェクトでは、typescriptでDIコンテナが使用できる、tsryingeパッケージを使用してDIを実現しています。
開発要件
この機能の要件は以下になります。
- ユーザーがキーワードを入力する
- AIがユーザーが入力したキーワードに基づいて関連するキーワード出力する
- AIが出力した関連キーワードで商品を検索する
- 関連キーワードは最大5件
- UIとして検索結果を表示する
今回の記事では商品検索以外のAIに関する要件について紹介します。
ドメイン層
「ドメイン駆動設計」なので、兎にも角にもドメインモデリングから始めます。
開発要件をまとめると、ドメインは以下の2つであることが分かります。
- AIが出力した関連キーワード
- 商品
今回の記事では「AIが出力した関連キーワード」に着目してモデリングの例を記載します。
まず、「AIが出力した関連キーワード」と呼び続けるのは長いため共通言語として、「関連キーワード」という言葉で定義します。
関連キーワードは以下の要素から構成されます。
- ユーザーが入力したキーワード
- 入力したキーワードに関連するキーワードリスト
つまり、「関連キーワード」のドメインモデルは「ユーザーが入力したキーワード」によって取得できる集約として定義することができます。
それを踏まえてコードに落とし込みます。
class RelatedKeyword {
public constructor(
public readonly relatedKeywordId: RelatedKeywordId,
public readonly keywordList: List<string>
) {}
}
interface Repository {
find(relatedKeywordId: RelatedKeywordId): Promise<RelatedKeyword>
}
これで完成、といきたいところですがドメインの要件の中に、「関連キーワードは最大5件」という要件が存在します。
この要件をコードとして表現する必要があります。
class RelatedKeyword {
public constructor(
public readonly relatedKeywordId: RelatedKeywordId,
public readonly keywordList: List<string>
) {
if (5 < keywordList.count()) {
throw new Error();
}
}
}
keywordList
が6件以上であれば例外を投げるif文を追加しました。
これによりRelatedKeyword
エンティティは6件以上のkeywordList
を持つことができなくなりました。
これで「関連キーワード」をコードで表現することができました。
アプリケーション層
アプリケーション層の役割はドメイン層が公開するメソッドを組み合わせ、アプリケーションとしてのユースケースを担当するレイヤーになります。
今回の機能では、プレゼンテーション層から渡された「ユーザーが入力したキーワード」を使用して「関連キーワード集約」を取得します。
リポジトリはDIを使用することで、レイヤーの依存関係を保持しています。
@injectable()
class RelatedKeywordApplication {
public constructor(
@inject('repository') private readonly repository: Repository
) {}
public async find(keyword: string): Promise<RelatedKeyword> {
const relatedKeywordId = new RelatedKeywordId(...);
return await this.repository.find(relatedKeywordId); // リポジトリを通して集約を取得する
}
}
インフラストラクチャ層
集約の永続化、APIやデータベースなどの外部リソースへのアクセスする役割を担うレイヤーになります。
ドメイン層でinterfaceで定義したリポジトリを実装します。
@injectable()
class ConcreteRelatedKeywordRepository implements Repository {
public constructor(
@inject('adaptor') private readonly adaptor: Adaptor
) {}
public async find(relatedKeywordId: RelatedKeywordId) {
return await this.adaptor.find(relatedKeywordId);
}
}
ACL層(Anti Corruption Layer)
外部リソースの使用の際に、外部との連携が不可能な場合には上流側の情報を下流側の自分のコンテキストに合うように変換する必要があります。(上流下流が逆のパターンも存在する)
こういった、「あちらの言葉」と「こちらの言葉」を相互に変換する役割を担うのがACL(Anti Corruption Layer: 腐敗防止層)になります。
外部サービスから情報を取得するためのClient
取得した値を変換するtranslator
変換されたものをラップするadaptor
https://zenn.dev/miya_tech/articles/fea4e4822a003c
ACLは上記の要素で構成されます。
今回はOpenAI APIという外部APIを使用するため、APIのレスポンス(あちらの言葉)を、関連キーワード(こちらの言葉)に変換しなければなりません。
@injectable()
class Adaptor {
public constructor(
@inject('client') private readonly: client: Client,
@inject('translator') private readonly: translator: Translator,
) {}
public async find(relatedKeywordId: RelatedKeywordId): Promise<RelatedKeyword> {
const request = // relatedKeywordIdや、必要なheaderなどあちらの言葉に合うようにrequest情報に変換する
const response = await this.client.call(request); // 外部APIを呼び出す
return this.translator.translate(await response.json()); // こちらの言葉に変換する
}
}
プレゼンテーション層
プレゼンテーション層は、リクエストの受付やレスポンスの返却など外部とのやり取りを担う役割のレイヤーになります。
Next.jsでは、レンダリングするページと、API Routeがそれに該当します。
本機能では、ユーザーから入力されたキーワードを受け付け、関連キーワードを取得し、それによる検索結果をページとしてレンダリングします。
const fetchRelatedKeyword = async (keyword: string): RelatedKeyword => {
const relatedKeywordApplication = container.resolve(RelatedKeywordApplication); // DIコンテナによるApplicationの取得
return await relatedKeywordApplication.find(keyword);
}
export default async function Page({ params }: Props) {
const relatedKeyword = await fetchRelatedKeyword(params.keyword);
const items = await itemUsecase... // 取得したrelatedKeywordを使って商品を検索
return <Main items={items} ... />;
}
まとめ
今回はフロントエンド×DDDで新機能の開発を行いました。
個人的にドメインモデルとコンポーネントの親和性はとても強いと感じています。
例として商品をレンダリングするコンポーネントに必要な情報は、当然商品に関する情報です。(商品名、価格、サムネイル、etc...)
商品に関する情報はDDDであれば、商品のドメインモデルとして表現されています。
コンポーネント = ドメインモデル
とも捉えられるかもしれません。
propsのバケツリレーが深くなると可読性が損なわれますが、propsがドメインモデルになることで
- レンダリングに必要な情報は何か
- このコンポーネントは何をレンダリングするコンポーネントなのか
といったことが明確になり、保守性、拡張性にすぐれていると感じました。
Wanted!!
BEENOSグループでは一緒に働いて頂けるエンジニアを強く求めております!
少し気になった方は、社内の様子や大事にしていることなどをThe BEENOSにて発信しておりますので、是非ご覧ください。
とても気になった方はこちらで求人も公開しておりますので、お気軽にご応募ください!
「自分に該当する職種がないな...?」と思った方はオープンポジションとしてご応募頂けると大変嬉しく思います 🙌
Discussion