ドメイン駆動設計を実践して自分の LINE 環境をリファクタリングしてみた(実装編)

「ドメイン駆動設計を実践してみた」の続き、実装編ですが、前記事の PV が結構あったのでビビってます。
2023.12.31

こんにちは、高崎@アノテーション です。

はじめに

前回の投稿 の続きになりますが、今回は実装編です。

実装したソースを見ると結構デカくなったので、まずはメインのドメインモデルとなるメモ保存データリポジトリmemoStoreの実装と、今回もう一つ、実践しようとしている DI コンテナを実践した実装をブログの記事にしたいと思います。

ベースとなるソースについては下記に記載しています。

なお、前回の投稿にも記載しましたが、正確性としては程遠い可能性があり、実装もエラーチェック等も甘い実装例として記載していること、予めご了承ください。

参考文献

memoStore のドメインモデル

基本的な考え方

筆者は考えが古いためか、設計パターンはデータクラスと機能クラスを分けて定義する観点で沁みついてしまっていました。

外部のソースがデータクラスを扱えないと自由度が無くていけないのでは?という観点がなかなか抜けませんでしたが、参考文献を読みながら少しずつ以下のような形で整理するようにしました。

  • あくまで機能を意識して扱うような仕組みを提供する
  • データクラスは機能内部で扱う目的で定義する
  • interface と実態を分けて実装する

これらを踏まえて、次項以降でこの機能のディレクトリ構成、ソースを実装していきます。

ディレクトリ構成

ベース環境がsrc/lambda配下で作っていたので、ここをルートとして 1 memoStore を以下のようなディレクトリ構成にしました。

  ¥(src/lambda)
  ┣ domain
  ┃ ┗ model
  ┃    ┗ memoStore
  ┃      ┣ memoStore.ts                   ・・・memoStore のデータ実体定義
  ┃      ┗ memoStore-repository.ts        ・・・外部提供する interface 定義
  ┗ infrastracture
     ┗ repository
       ┗ memoStore-dynamodb-repository.ts  ・・・interface 定義を実装する実体

interface 定義の実体側については、DynamoDB を扱うので名前付けもそのように行いました。

ソース

データ実体の定義

DynamoDB に保存するデータの定義になります。

前回よりフィールド名も少し付け加えてキャメルケースで実装しました。

domain/model/memoStore/memoStore.ts

export interface MemoStore {
  lineUserId: string
  messageId: number
  memoText: string
  storedTime: string
}

export type MemoStores = ReadonlyArray<MemoStore>;

外部への interface 定義

interface 定義です。

ベースには無かったのですが、全データ取得も機能として用意しました。

domain/model/memoStore/memoStore-repository.ts

import { MemoStore, MemoStores } from "./memoStore";

export type MemoStoreResult = MemoStore;
export type MemoStoresResult = MemoStores;
export interface DeleteItemProc {
  lineUserId: string;
  messageId: number;
}

export interface MemoStoreRepository {
  getAll(): Promise<MemoStoresResult>;
  getMemosFromLineId(lineUserId: string): Promise<MemoStoresResult>;
  putItem(item: MemoStore): Promise<void>;
  deleteItem(item: DeleteItemProc): Promise<void>;
}

interface 実体

前項の実体ですが、コンストラクタにテーブル名と DynamoDB のサービスインスタンスを指定するようにしました。

ScanCommand や QueryCommand で取得した後、memoStore へ格納する処理は Zod を使って値チェックを行う、別途 private 関数を用意して格納する、といった改善は今後の課題ですね。

150行超えたので折りたたんでいます。

infrastracture/repository/memoStore-dynamodb-repository.ts

import {
  DynamoDBDocumentClient,
  ScanCommand,
  QueryCommand,
  PutCommand,
  DeleteCommand
} from "@aws-sdk/lib-dynamodb";
import { MemoStore } from "../../domain/model/memoStore/memoStore";
import {
  DeleteItemProc,
  MemoStoresResult,
  MemoStoreRepository
} from "../../domain/model/memoStore/memoStore-repository";

export class MemoStoreDynamoDBRepository implements MemoStoreRepository {
  private readonly dbDocument: DynamoDBDocumentClient;
  private readonly memoStoreTableName: string;

  constructor({
    dbDocument,
    memoStoreTableName,
  }: {
    dbDocument: DynamoDBDocumentClient,
    memoStoreTableName: string,
  }) {
    this.dbDocument = dbDocument;
    this.memoStoreTableName = memoStoreTableName;
  }

  /**
   * 全件取得(プライマリキー関係なく全件取得する)
   * @returns MemoStore 全件
   */
  async getAll(): Promise<MemoStoresResult> {
    const command = new ScanCommand({
      TableName: this.memoStoreTableName,
    })
    const { Items: items = [] } = await this.dbDocument.send(command);
    if (items.length === 0) {
      return [];
    }
    const scanedItems: MemoStore[] = [];
    items.map((item) => {
      const element: MemoStore = {
        lineUserId: item["lineUserId"] || "",
        messageId: Number(item["messageId"] || "0"),
        memoText: item["memoText"] || "",
        storedTime: item["storedTime"] || "",
      }
      scanedItems.push(element);
    })
    return scanedItems;
  }

  /**
   * lineUserId に関係するデータ全件取得
   * @param lineUserId キーとなる lineUserId
   * @returns lineUserId に該当する MemoStore 全件
   */
  async getMemosFromLineId(lineUserId: string): Promise<MemoStoresResult> {
    const command = new QueryCommand({
      TableName: this.memoStoreTableName,
      KeyConditionExpression: "lineUserId = :userid",
      ExpressionAttributeValues: { ":userid": lineUserId },
      ScanIndexForward: false,
    })
    const { Items: items = [] } = await this.dbDocument.send(command);
    if (items.length === 0) {
      return [];
    }
    const queryItems: MemoStore[] = [];
    items.map((item) => {
      const element: MemoStore = {
        lineUserId: item["lineUserId"] || "",
        messageId: Number(item["messageId"] || "0"),
        memoText: item["memoText"] || "",
        storedTime: item["storedTime"] || "",
      }
      queryItems.push(element);
    })
    return queryItems;
  }

  /**
   * lineUserId に該当する messageId の最終番号を取得する
   * @param lineUserId キーとなる lineUserId
   * @returns messageId の最終番号(無い場合は0、エラー時は-1)
   */
  private async getLastCount(lineUserId: string): Promise<number> {
    // 逆順で1つだけ取得
    const command = new QueryCommand({
      TableName: this.memoStoreTableName,
      KeyConditionExpression: "lineUserId = :userid",
      ExpressionAttributeValues: { ":userid": lineUserId },
      ScanIndexForward: false,
      Limit: 1,
    });
    try {
      const { Items: items = [] } = await this.dbDocument.send(command);
      if (items !== undefined) {
        // 一つもなかった場合は Items が空配列なのでゼロを返す
        if (items.length === 0) {
          return 0;
        }
        // messageId が undefined の場合は -1 で終了
        if (items[0]["messageId"] === undefined) {
          console.error("getLastCount : messageId undefined.");
          return -1;
        }
        return Number(items[0]["messageId"]);
      }
      console.error("getLastCount : items undefined.");
      return -1;
    } catch (error) {
      console.error("getLastCount : ", error);
    }
    return -1;
  }

  /**
   * データ1件追加
   * @param item 追加したいデータ
   */
  async putItem(item: MemoStore): Promise<void> {
    const numLastId: number = await this.getLastCount(item.lineUserId);
    try {
      if (numLastId === -1) {
        // 失敗したら登録せず throw して終了
        throw new Error("最大値取得に失敗");
      }
      const command = new PutCommand({
        TableName: this.memoStoreTableName,
        Item: {
          lineUserId: item.lineUserId,
          messageId: numLastId + 1,
          storedTime: item.storedTime,
          memoText: item.memoText,
        },
      });
      await this.dbDocument.send(command);
    } catch (error) {
      console.error("putItem :", error);
    }
  }

  /**
   * データ1件削除
   * @param item lineUserId、messageId パラメータ(この2つでユニークになる)
   */
  async deleteItem(item: DeleteItemProc): Promise<void> {
    const command = new DeleteCommand({
      TableName: this.memoStoreTableName,
      Key: {
        lineUserId: item.lineUserId,
        messageId: Number(item.messageId),
      },
    });
    try {
      await this.dbDocument.send(command);
    } catch (error) {
      console.error("deleteItem :", error);
    }
  }
}

DI コンテナ化の実践

さて、もう一つのテーマが DI コンテナを定義してみる、というのがありまして、今回その実践となります。

DI コンテナについては下記の記事が参考になります。

今回は inversify を使い、下記の記事を参考に実装しました。

実装

ディレクトリ構成

ディレクトリ構成は下記です。

  ¥(src/lambda)
  ┗ di-container
     ┗ register-container.ts

ソース

memoStore に関わる DI コンテナを構築してみました。

必要なデータ整理は MemoStoreRepository の観点から見ると以下になります。

  • MemoStoreRepository には DynamoDB のサービスを扱う DynamoDBDocumentClient クラスインスタンス、テーブル名の文字列が必要
  • DynamoDBDocumentClient クラスは DynamoDBClient クラスインスタンスが必要
  • DynamoDBClient クラスインスタンスは AWS リージョンが必要

以上を踏まえて、以下のようなコンテナ定義になります。

di-container/register-container.ts

import { Container } from 'inversify'; 
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { MemoStoreDynamoDBRepository } from '../infrastracture/repository/memoStore-dynamodb-repository';

export const ID_AWS_REGION = "ID_AWS_REGION" as const;
export const ID_TABLE_NAME = "ID_TABLE_NAME" as const;
export const ID_DYNAMODB = "ID_DYNAMODB" as const;
export const ID_DYNAMODB_DOCUMENT = "ID_DYNAMODB_DOCUMENT" as const;
export const ID_MEMO_STORE_REPOSITORY = "ID_MEMO_STORE_REPOSITORY" as const;

export const initContainer = (): Container => {
  const container = new Container();

  // 環境変数
  container
    .bind(ID_AWS_REGION)
    .toDynamicValue(() => process.env.AWS_REGION || "")
    .inSingletonScope();
  container
    .bind(ID_TABLE_NAME)
    .toDynamicValue(() => process.env.TABLE_NAME || "")
    .inSingletonScope();

  // DynamoDB
  container
    .bind(ID_DYNAMODB)
    .toDynamicValue((context) => new DynamoDBClient({
      region: context.container.get<string>(ID_AWS_REGION)
    }))
    .inSingletonScope();
  container
    .bind(ID_DYNAMODB_DOCUMENT)
    .toDynamicValue((context) =>
      DynamoDBDocumentClient.from(context.container.get<DynamoDBClient>(ID_DYNAMODB)))
    .inSingletonScope();

  // memoStore データリポジトリ
  container
    .bind(ID_MEMO_STORE_REPOSITORY)
    .toDynamicValue((context) => 
      new MemoStoreDynamoDBRepository({
        dbDocument: context.container.get<DynamoDBDocumentClient>(ID_DYNAMODB_DOCUMENT),
        memoStoreTableName: context.container.get<string>(ID_TABLE_NAME),
      })  
    )
    .inSingletonScope();

  return container;
}

使用したければ、下記のように呼び出すことで扱えます。

sample.ts

import { ID_MEMO_STORE_REPOSITORY, initContainer } from "di-container/register-container";
import { MemoStoreRepository } from "domain/model/memoStore/memoStore-repository";

// コンテナ初期化
const container = initContainer();
// MemoStoreRepository 取得
const memoStoreRepository: MemoStoreRepository = container.get<MemoStoreRepository>(ID_MEMO_STORE_REPOSITORY);
   :

おわりに

今回は、ベースソースからメモ機能におけるドメインモデル周りと DI コンテナの実装を行いました。

あくまで「実践してみた」一例でしてこれが正解ではないと思いますが、ご参考になれば幸いです。

次回以降の続きは以下のようになるかと思います。

  • 次回:Bedrock を使った機能と LINE Messaging API 周りにおけるドメインモデルと DI コンテナ実装
  • 次回かその次にするか:LINEボットのハンドラとユースケースの実装

アノテーション株式会社について

アノテーション株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。
「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。
現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。
少しでもご興味あれば、アノテーション株式会社WEBサイト をご覧ください。


  1. 大元の構成については今後 LIFF のフロントエンドも構築したいと考えており、考え中です。