Zenn
🔘

ツイッター風アプリをTypeScript × DDD × クリーンアーキテクチャで実装してみた

2025/03/14に公開
66

こんにちは!ゲンシュンです。
最近データのETL処理をpythonで書くことが多くて、久しぶりに型でゴリゴリ開発したいな〜という気持ちになりました笑。久しくDDDとかクリーンアーキテクチャとかやってないので、今あらためて見直すと新しい発見や気付きあるかも〜と思い、TypeScript × Node.jsでそれっぽい実装をしてみました!正直間違えていること、浅い思考、微妙な実装たくさんあるかと思いますが、現段階で思考を整理してみたのをまとめた感じなので、流し読み程度で!

今回の目的は「DDDのエッセンスとクリーンアーキテクチャの依存の方向性を、Typescriptの型で表現してみる」で、twitter風アプリを実装してみました!アプリのユースケースは「Userがメールアドレスユニークという条件で新規作成」「UserがUserNameを変更」「UserがPostを作成」「UserがPostをお気に入りに追加/削除」「UserごとのPost一覧を取得」「Userのお気に入りに追加されたPost一覧を取得」という感じです。AggregteやServiceに使えると思い、雑なHashTag機能も実装したのですが、なんか微妙だったのでコードの残骸だけ残ってます。
参考にならないと思いますがソースコードはgithubにあげてます〜。

本記事では以下の順番に考えたことや悩みポイントを諸々まとめてます。

  • DDD/クリーンアーキテクチャのおさらい
  • 実装で意識したポイント
  • 実装で悩んだポイント
  • 感想

DDD/クリーンアーキテクチャのおさらい

本当に雑メモ程度なので読み飛ばしてください・・

DDDの主要概念

Entity: 一意のIDを持ち、ライフサイクルを通して同一性が維持される概念。
ValueObject: 識別の必要のないオブジェクト。値によって定義される概念。
Aggregate: 関連するエンティティと値オブジェクトの境界を定義する集合体。
Factory: オブジェクトの生成に関して、ビジネスロジックがカプセル化されたもの。
Repository: オブジェクトの永続化と取得を抽象化したもの。

クリーンアーキテクチャの概念

ドメイン(ビジネスロジック)を中心に捉え、インフラレイヤーなどの技術的やことを外側に配置し、依存関係を外から内側への一方向にする設計です。
ドメイン層:一番重要な層。ビジネスロジックを含む。外側の層は全てここに依存するし、ドメイン層は外側に一切依存しない。
アプリケーション層:ユースケースを実装するレイヤーで、ドメイン層に存在するオブジェクトを登場させビジネスロジックを実行。

ここより外側は色々用語があるので、ちょこちょこ書いてみる。
ポート:アプリケーション層の入力出力のインターフェースを指す。
コントローラー:リクエストを受け取り、ユースケースを呼び出す。
アダプター:フレームワークやデータベースなど外側の事情とアプリケーションを橋渡しする概念。
プレゼンター:ユースケースの実行結果を、外側の世界に表現を変えて出力する(HTTPレスポンスなど)
ゲートウェイ:リポジトリの実装で、実際のデータが存在する世界への通信を行う。

よくみる図です。引用元

実装で意識したポイント

ディレクトリの切り方について

機能軸、レイヤー軸ありますが今回は機能軸で切ってみました。DDDはドメインごとの境界(Boundary Context)を明確にすることを重視しているので、機能=ドメイン単位で凝集度を高めたほうが良さそうという判断。一方レイヤー軸は、ドメインを横断する変更や処理が多い時に向いてそう。例えばuser周りで機能追加や修正が発生する場合、以下の例を見るとレイヤー軸よりも機能軸のほうが特定のディレクトリ配下の変更で済みそうなので見通しが良さそうな雰囲気。
ドメインの境界をキレイに表現できれば機能軸で切れるイメージは湧きますが、実際切れるもんですかねぇ〜?なんとなーく、レイヤー軸で切りがちな気もしてます。

# レイヤー軸でのディレクトリの切り方だとこんな感じ
domain
├── entity
│   ├── user
│   └── post
├── valueObject
│   ├── user
│   └── post
├── factory
│   ├── user
│   └── post
└── repository
    ├── user
    └── post

# 機能別でのディレクトリの切り方だとこんな感じ
domain
├── user ← userの変更はここ配下だけいじれば済むはず!
│   ├── entity
│   ├── valueObject
│   ├── factory
│   └── repository
└── post
│   ├── entity
│   ├── valueObject
│   ├── factory
│   └── repository
...他同様

また、クリーンアーキテクチャによく出てくる円の図を意識するために、円ごとにディレクトリを別けてみました。

  • domain層: 真ん中の円。DDDでいうドメイン関連が登場。
  • application層: 2番目の円。ユースケースにまつわる処理。
  • adaptor層: 一番外側の円。controller、presenter、infrastructureの3つ登場。
modules                     # 機能別モジュール
├── user                    # ユーザー機能
│   ├── adaptor             # 外部との接続層
│   │   ├── controller      # UseCase呼び出す君
│   │   ├── infrastructure  # データベース周り
│   │   └── presenter       # Output
│   ├── application         # アプリケーション層
│   │   ├── dto             # レイヤーまたぐときのobject変換
│   │   ├── port            # Portのinterface
│   │   └── usecase         # ドメインモデルを使って実現したい処理を行う
│   └── domain              # ドメイン層
│       ├── entity
│       ├── factory
│       ├── repository
│       └── valueObject
├── post                    # 投稿機能
│       ...他同様
shared                      # 共通コンポーネント/基底クラス
├── adaptor
├── application
└── domain

依存の方向性を型で表現してみる

クリーンアーキテクチャの肝である「依存関係が外から内へ」という原則を、TypeScriptの型で表現しました。今回型で気持ちよくなりたかったので、諸々基底クラスとか実装しましたが、実際はこんなには要らないかも〜?
主要な基底クラスと具体のクラスについて以下いくつか紹介です!

ValueObject

識別の必要がない不変的な値を扱う。値はgetValue()で取り出し、バリデーションはabstractメソッドを用意して継承先で実装。

// 値オブジェクトの基底クラス
export abstract class ValueObject<T> {
  protected constructor(protected readonly value: T) {
    this.validate(value);
  }
  protected abstract validate(value: T): void; //継承先でバリデーションの具体を実装

  equals(vo: ValueObject<T>): boolean {
    return this.value === vo.value;
  }

  getValue(): T {
    return this.value;
  }
}

// 具体の値オブジェクトの実装
export class Email extends ValueObject<string> {
  constructor(value: string) {
    super(value);
  }

  protected validate(value: string): void {
    if (!this.isValidEmail(value)) {
      throw new Error(`Invalid Email: ${value}`);
    }
  }

  // クソ雑な正規表現
  private isValidEmail(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }
}

Entity

識別の必要のあるオブジェクトということは、識別子が必ず存在するので、EntityIDを継承するようにしてみた。
ここで一瞬悩んだことを書くと、A extends B の継承関係ってぱっと見、親子関係に思えてあれ?ってなったんですが、これは継承というより、A has Bという含まれている型を表現しているので別に問題ないかーとなりました笑。
なのでEntityIDを継承しているというより、Entity has EntityIDという型なんだよーという感じ。

// 基底クラス
export abstract class Entity<T extends EntityID<V>, V> {
    protected constructor(protected readonly id: E) {}

    getID(): T {
        return this.id;
    }

    equals(entity: Entity<T, V>): boolean {
        return this.id.equals(entity.getID());
    }
}

// 具体の実装
export class HashTag extends Entity<HashTagID, string> {
    constructor(
        readonly id: HashTagID,
        private readonly text: Text,
    ) {
        super(id);
    }

    getID(): HashTagID {
        return this.id;
    }

    getText(): Text {
        return this.text;
    }
}

Repository

Repositoryは、Entityを生成するので、Entityをextendsするようなinterfaceを用意してみた。型の表現として「「IDを持つEntity」を持つRepository」になるので、extendsが多くなっちゃうなー。
またfindByID系のメソッドって、無かった時Nullを返すので Promise<X | null> になるの嫌だなー。Scalaの Option[X] みたいなのほしいな〜というお気持ちに。今回はその手のライブラリは使わないと決めたのでしゃーなし!

// 基底クラス
export interface Repository<E extends Entity<ID, V>, ID extends EntityID<V>, V> {
    // どんなrepositoryも持ちそうなメソッドだけ用意
    save(entity: E): Promise<void>;
    findOneByID(id: ID): Promise<E | null>;
    findAll(): Promise<E[]>;
}

// 具体の実装
export interface UserRepository extends Repository<User, UserID, string> {
    save(user: User): Promise<void>;
    findOneByID(id: UserID): Promise<User | null>;
    findAll(): Promise<User[]>;
    findByEmail(email: Email): Promise<User | null>; //UserRepositoryだけのメソッド
}

Controller、UseCase、Presenterの依存関係について

クリーンアーキテクチャのPort周りで、各Port、UseCase、Presenterの依存関係を型で表現してみた。

// application/InputPort
export interface InputPort<ApplicationDto> {
    // UseCaseの実行メソッドだけ用意
    execute(request: ApplicationDto): Promise<void>;
}
// application/UseCase
export abstract class UseCase<T> implements InputPort<T>{
    // 後述しますがconstructorでoutputPort(Presenterインスタンス)を渡すのをやめましたがここでは一旦渡す
    protected constructor(
        protected readonly outputPort: OutputPort
    ) {}
    // 具体クラスで、Domain層にいる登場人物を使ってユースケースを処理し
    // 成功したらoutputPortを実行、失敗したらfailureを実行
    abstract execute(request: RequestDto): Promise<void>;
}
// application/OutputPort
export interface OutputPort {
    // 共通で呼びされれるであろう失敗メソッドだけ用意
    failure(error: Error): void;
}
// adaptor/Presenter
export abstract class Presenter implements OutputPort {
    constructor(protected response: T) {}
    failure(err: Error): void {
        // 一旦500返す
        this.response.status(500);
        this.response.send(err);
    }
}

ControllerがUseCaseを呼び出す
UseCaseはInputPortに依存している
UseCaseがPresenterを呼び出す
PresenterはOutputPortに依存している
という、Controller →呼び出し→ InputPort ←継承← UseCase →呼び出し→ OutputPort ←継承← Presenter という依存関係を表現できていそう!

Userに関する一連の処理を一旦ざっくり実装してみる。

// adaptor/controller/UserController.ts (入力アダプター)
export const UserController = {
  createUser(req: Request, res: Response): void {
    usecase.execute(request);
  }
}

// application/usecase/CreateUserUseCase.ts (ポートの実装)
export class CreateUserUseCase implements CreateUserInputPort {
  constructor(
    private readonly outputPort: UserPresenter,
    private readonly userRepository: UserRepository,
    private readonly userFactory: UserFactory,
  ) {}
  async execute(request: CreateUserRequest): Promise<void> {
    const user = this.userFactory.create(request.name, request.email); //factory経由で生成
    await this.userRepository.save(user); //永続化
    this.outputPort.successCreateUser({user}); //presenterに渡す
  }
}

// adaptor/presenter/UserPresenter.ts (出力アダプター)
export class UserPresenter implements UserOutputPort {
  constructor(protected response: Response) {}
  successCreateUser(userResponse: CreateUserResponse): void {
    const userDto = convertUser2UserDto(userResponse.user);
    this.response.send(userDto);
  }
}

Controllerの実装

いい感じに基底クラス作れず(作ることが目的じゃないですけど)。野望だけ残しておきます。

// 基底クラス作れたらこんな感じで共通化できそうだった
export abstract class Controller<RequestDto> {
    protected constructor(
        protected readonly useCase: InputPort<RequestDto>,
        protected readonly presenter: OutputPort
    ) {}

    protected abstract validateRequest(req: RequestDto): boolean;
    // controllerとusecaseが1対1ならrunするだけでいけそう
    run(res: Response, req: Request): void {
        // request自体のバリデーション
        const validationResult = this.validateRequest(req);
        switch(validationResult) {
            case true:
                // usecaseの実装し内部でpresenterを実行しresponse返す
                this.useCase.execute(req);
                break;
            case false:
                // requestエラーなので、usecase層にいかずpresenterのfailureを実行する
                this.presenter.failure(new Error("validation error"));
            default:
                break;
        }
    }
}

Presenterの実装

後述しますが、expressのフレームワークに依存する実装になってます。


import { Response } from 'express';
export abstract class Presenter implements IOutputPort {
    constructor(protected response: Response) {}
    failure(error: Error): void {
        this.response.status(500);
    }
}

依存性の注入

Controllerから実装してみると、以下みたいにnew祭りになりそう。
依存関係を一箇所で管理したり、モックに置き換えやすくしたり等、このあたり良くしたい!

// ControllerまたはUseCase内で、new祭りによる毎回インスタンス化させることになりそう
export const CreateUserController {
    const repository = new UserRepository();
    const presenter = new UserPresenter();
    const useCase = new CreateUserUseCase(repository, presenter);
    return new CreateUserController(useCase);
}

てことで、DIコンテナを使ってみる。

import { container } from "tsyringe";
// UserRepositoryのインターフェースに対して、UserGatewayの実体を紐づける
// アプリケーション起動時に依存関係が解消される!
container.registerSingleton<UserRepository>("UserRepository", UserGateway);
container.registerSingleton<UserFactory>("UserFactory", UserFactory);

// 実体側で @inject デコレーターを使って依存関係を解決する必要あり
import { inject, injectable } from "tsyringe";

@injectable()
export class CreateHashTagUseCase extends UseCase<CreateHashTagRequest> implements CreateHashTagInputPort {
    constructor(
        // さっきまでの実装に@injectをする
        // private readonly hashTagRepository: HashTagRepository,
        // private readonly hashTagFactory: HashTagFactory,
        @inject("HashTagRepository") private readonly hashTagRepository: HashTagRepository,
        @inject("HashTagFactory") private readonly hashTagFactory: HashTagFactory,
    ) {
        super();
    }
}

実装で悩んだポイント

その1. 単純なEntity or Aggregateの判断

Aggregateの役割としては「関連するエンティティと値オブジェクトの境界を定義する集合体」なんですが、エンティティごとに境界を明確に別けられるのならEntityで良いが、複数のエンティティが絡むものだとAggregateが登場するんだろうな〜ぐらいの理解にとどまっています。今回PostにはFavoriteという概念がありまさひて「お気に入りを追加/削除」というユースケースがあります。これをPostEntityとFavoriteEntityとするか、PostAggregateとするかに悩み。

Aggregateの強みはエンティティをまたいだ整合性を担保できる所だと思い、例えば「投稿を削除したときに、関連するFavoriteを消す」みたいなのって、個々のEntityで実装するよりAggregateでまとめたほうが良さそうだなーと。この事情(ビジネスロジック)をAggreate内に集約させて、トランザクションをAggregate単位で管理すれば、部分更新による不整合とか起きづらそうだ!

もともとPostEntityとPostRepositoryを作っていたのですが、FavoriteEntityを作るに当たりPostAggregateに変更。PostRepositoryを削除しPostAggregateRepositoryを新設

// Aggregate作る前の実装
export class Post extends Entity<PostID, string> {
    constructor(
        readonly id: PostID,
        private readonly message: Message,
    )
}
export interface PostRepository extends Repository<Post, PostID, string> {
    save(post: Post): Promise<void>;
    findOneByID(id: PostID): Promise<Post | null>;
    findAll(): Promise<Post[]>;
}

// Aggregate作ったあと、こんな感じで実装したい!
export class PostAggregate extends Aggregate<Post, PostID, string> {
    constructor(
        readonly id: PostID,
        private readonly message: Message,
    )
}
export interface PostAggregateRepository extends Repository<PostAggregate, PostID, string> {}

実際はうまくいかず PostAggregateRepository がPostAggregateはEntityじゃないよという型エラーがでちゃいました。
以下のような実装もありですが、今回はAggregate用のRepositoryを作りました

//EntityとAggregateが型として互換性があれば、Entityの部分がAggregateになるだけで済むはず
export interface AggregateRoot<ID extends EntityID<V>, V> {
    getID(): ID;
    equals(entity: AggregateRoot<ID, V>): boolean;
}
// しかし、Postを返却したいケースと、PostAggregateを返却したいケースと、Favoriteを返却したいケースがあるので、共通化すると逆に面倒では?
export interface PostAggregateRepository {
    save(post): Promise<void>;
    findOneByID(postID): Promise<Post | null>;
    saveFavorite(postID, userID): Promise<Favorite>;
    saveUnfavorite(postID, userID): Promise<Favorite>;
    findAll(): Promise<PostAggregate[]>;  
}

てことで、最終的にこんな感じで実装してみた。

// 基底クラス
export interface AggregateRepository<
    A extends Aggregate<E, ID, V>,
    E extends Entity<ID, V>,
    ID extends EntityID<V>,
    V> {
    save(entity: E): Promise<void>;
    findOneByID(id: ID): Promise<E | null>;
    findAll(): Promise<A[]>;
}

export interface PostAggregateRepository extends AggregateRepository<PostAggregate, Post, PostID, string>{}

// 集約の実装例
export class PostAggregate extends Aggregate<Post, PostID, string> {
  constructor(
    readonly id: PostID,
    readonly rootEntity: Post,
    private readonly favorites: Favorite[]
  ) {
    super(id, rootEntity);
  }

    addFavorite(userID: UserID): PostAggregate {
        // 既にお気に入り済みなら何もしない
        if (this.isFavorited(userID)) {
            // エラーを返すかどうか検討
            return this;
        }
        // Factoryで実装するのもあり
        const favorite = new Favorite(
            FavoriteID.create(crypto.randomUUID()),
            userID,
            this.getRoot().getID(),
            new Date()
        );
        // 新しいRootEntityを返す
        return new PostAggregate(
            this.getID(),
            this.getRoot(),
            [...this.favorites, favorite]
        );
    }
}

関連エンティティのアクセスを集約ルート(今回はPost)経由して制御できるし、良さそうかな?集約内の他Entityは、関係のない外部のオブジェクトから直接参照させない、変更させない、Aggregate間の参照はIDのみにする、操作は新しい集約を返すことでImmutableな実装ができる、などを意識してみた感じです。

その2. PresenterとDIコンテナの関係

先ほどちらっと書きましたが、PresenterがExpressに依存しているので、DIコンテナでシングルトンとして登録出来ないことがわかりました。Presenterのconstrutorにresponseオブジェクトを受け取るような実装をしており、Expressのresponseオブジェクトは、リクエストごとに違うのでシングルトンに出来ない的なノリです。

// PresenterがコンストラクタでexpressのResponseを必要としている
import { Response } from 'express';
export abstract class Presenter implements IOutputPort {
    constructor(protected response: Response) {}
    failure(error: Error): void {
        this.response.status(500);
    }
}

// DI実装ができない
import { container } from "tsyringe";
container.registerSingleton<UserRepository>('UserRepository', UserGateway);
container.registerSingleton<UserFactory>('UserFactory', UserFactory);
container.registerSingleton<UserPresenter>('UserPresenter', UserPresenter);

@injectable()
export class CreateUserUseCase extends UseCase<CreateUserRequest> implements CreateUserInputPort {
    constructor(
        @inject("UserPresenter") readonly outputPort: UserOutputPort,
        @inject("UserRepository") private readonly userRepository: UserRepository,
        @inject("UserFactory") private readonly userFactory: UserFactory,
    ) {
        super(outputPort);
    }
}

// UserPresenterがどのResponseオブジェクトを使うべきかの情報が欠けているので、インスタンス化する際に問題が発生
container.registerSingleton<UserPresenter>('UserPresenter', UserPresenter);

てことで、Presenterは毎回インスタンス化して、UseCase実行前に依存注入して生成するようにしてみた。

// UseCase.ts
export abstract class UseCase implements InputPort {
  protected outputPort?: OutputPort;
  // setterメソッドで生成
  setOutputPort(outputPort: OutputPort) :void {
    this.outputPort = outputPort;
  }
}

// UserController.ts
const usecase = container.resolve(CreateUserUseCase);
// UseCaseごとに、そのUseCaseに必要なresponseを渡して都度インスタンスを生成する
const presenter = new UserPresenter(res);
usecase.setOutputPort(presenter);
usecase.execute(request);

感想

久しぶりにtypescriptで型〜が出来て楽しかった笑。
DDDとかクリーンアーキテクチャとか原理原則をそのまま忠実に実装すると、実際の運用が大変だったりするので、大事なエッセンスを抽出して適応するのが大事なんだとは思いますが、いざサンプル実装を作っても悩みポイントちょこちょこ出てきたんで、難しいなーと。
今回やりきれなかったのは2点ありまして、1つ目は「変更に強いを体感してみた」系です。例えばDBをキャッシュからMySQLに変えた時、外部の層の修正のみにとどまって、dao周りだけ対応するみたいなのはやりたかったんですが、ちょと面倒だったのでいいやーってなりました。。w DIでテスト容易性も体感してみたかった。

もう1点は、以下の実装をいい感じに入れられなかったなーと。UseCaseの実行結果を持つ型を用意して、成功したらPresenterのsuccessを、失敗したらPresenterのfailureを実行する的な。型でパターンマッチっぽいことをやりたかっただk(ry

export type Success<T> = {
    readonly type: "success";
    readonly value: T;
}

export type Failure = {
    readonly type: "failure";
    readonly error: Error;
}

export type Result<T> = Success<T> | Failure;

export class UseCaseResult {
    static success<T>(value: T): Success<T> {
        return {
            type: "success",
            value
        };
    }

    static failure(error: Error): Failure {
        return {
            type: "failure",
            error
        };
    }

    static isSuccess<T>(result: Result<T>): result is Success<T> {
        return result.type === "success";
    }

    static isFailure<T>(result: Result<T>): result is Failure {
        return result.type === "failure";
    }

    static getValue<T>(result: Result<T>): T {
        if (this.isSuccess(result)) {
            return result.value;
        }
        throw result.error;
    }

    static getError<T>(result: Result<T>): Error {
        if (this.isFailure(result)) {
            return result.error;
        }
        throw new Error("Cannot get error");
    }
}

久しぶりのtypescript系記事でした!以上です、ありがとうございました!

66

Discussion

ログインするとコメントできます