NestJSとPrismaで頑張らないDDD。設計ガイド編(他の技術でも展開可)

ここ数年でレイヤードアーキテクチャやクリーンアーキテクチャ、オニオンアーキテクチャそしてDDDという言葉を急激に聞くようになってきました。 私個人は何年かGolangをクリーンアーキテクチャで運用したことがあります。 その経験の中ではわりと辛い側面も多く、次にやるならこうすると思っていた構想をNestJSにぶつけてみました。

読んでいただいている方と私の知識のベースラインを揃えるために、割と文字が多めですが最後まで読んでいただけると幸いです。

実装だけを知りたい方は別記事で出しますので是非ウォッチしてお待ちいただきたいです!

DDDする前に前置き

DDDで出てくる言葉については、こちら非常にうまくまとまっていたので日本語訳して適宜削減と修正をおこなっております。本当に感謝します。

また併せて、ADR(Architecture Decision Record)としてなぜそうしたのかの経緯も記載しておりますのでご参考までに読み合わせてみてください!フィードバックもお待ちしてます。

文中で出てくる 打ち消し線 は元々の定義や引用を修正した箇所を示すために使用しております!

もちろん全てをDDDの設計パターンで実装するわけではなくてあくまで重要なところのみです。そこまで重要でないものはCRUDでサクッと終わらせます!

ADR(Architecture Decision Record)とは

テキストベースの軽量なテンプレートを使用して、アーキテクチャ上の設計判断を記録する。軽量なアーキテクチャデシジョンレコード(Architecture Decision Records:ADR)は、実績のあるアーキテクチャ手法に対する開発者寄りのアプローチだ。設計判断を記録していくことで、それらを共有し分析することが容易になる。意思決定の履歴を残すことで、現在のアーキテクチャについてのコンテキストを、その過程と結びつけて提供できる。 - Michael Keeling、島田浩二 訳、2019「Design It! プログラマーのためのアーキテクティング入門」、オライリー・ジャパン P302

また実際の運用はスタディサプリさんのブログが非常にイメージがつきやすかったです。

私がADRへ込めた思い。

ADRもルールというわけで設置しているわけではなくて、設計意図を残すことで、実装者がその意図だったら変更していいなという判断をしやすくするものです。

下記のよくある精神的ハードルものを解消するために記載してます。

  • 設計者の意図がわからないから、とりあえず従ってみて無理やり合わせて実装が複雑化してしまう。
  • もっといい方法があるのに設計意図がわからないから変更しずらい

フォーマットにこだわらずとりあえず残すことを重視してます!!!

NestJSで頑張らないDDDのための認識合わせ 目次

NestJS頑張らないDDD登場人物図解

アプリケーションサービス

NestJS頑張らないDDD では commandHandler として登場

「ワークフローサービス」、「ユースケース」、「インタラクタ」などとも呼ばれます。これらのサービスは、クライアントから課されたコマンドを実行するために必要なステップを組織的によびだします。

一般的に、外部の世界がアプリケーションとどのように相互作用し、エンドユーザーが必要とするタスクを実行するかをオーケストレーションするために使用されます。

ユースケースごとに 1 つのサービスを提供することが良いとされています。

コマンド

コマンドは、ユーザーの意図を伝えるオブジェクトで、例えば、CreateUserCommandがあります。これは、1つのアクションを記述します。(ただし、実行はしません。)

コマンドは、新しいユーザーを作成してデータベースに保存するような、状態を変更する操作に使用されます。Create、Update、Deleteの各操作は、状態を変更する操作とみなされます。

データ検索はクエリの責任であり、コマンドメソッドはビジネスデータを返してはなりません。 ※adr9 NestJS頑張らないDDDにおいては、エンティティの内部の情報をDtoにマッピングして返すことを許容します。

CQSの純粋主義者の中には、Commandは何も返すべきではないと言う人もいるかもしれません。しかし、作成されたアイテムに後でアクセスするためには、少なくともそのIDが必要である。そのために、クライアントにUUIDを生成させることができます(詳細はこちら:CQSとサーバーが生成するIDの比較)。

しかし、このルールを破って、作成されたアイテムのID、リダイレクトリンク、確認メッセージ、ステータスなどのメタデータを返すことは、実用的なアプローチであります。

コマンドを実行するには、サービスを直接インポートする代わりに、Command Busを使うことができます。これにより、コマンドのInvokerとReceiverが切り離されるので、カップリングを作ることなく、どこからでもコマンドを送信できるようになります。 ※adr2 NestJS頑張らないDDDでは実装のシンプルさをとって、NestjsのCommand Busの利用は避けている。

コマンドハンドラがこのような形で他のコマンドを実行することは避けてください。コマンド→コマンドのように、コマンドハンドラで他のコマンドを実行することは避けましょう。代わりに、イベントを使い、イベントハンドラで次のコマンドを連鎖的に実行します。コマンド → イベント → コマンドです。 NestJS頑張らないDDDではCloudPubSubを使用して実現するコマンド → イベント(CloudPubSub) → コマンド として構築します。

エンティティ

エンティティはビジネスモデルを表し、特定のモデルがどのような特性を持ち、いつ、どのような条件で何ができるかを表現します。ビジネスモデルの例としては、ユーザー、商品、予約、チケット、ウォレットなどがあります。

ドメイン・エンティティは常に有効なエンティティでなければならない。オブジェクトには、常に真でなければならない不変項がいくつかあります。例えば、注文品オブジェクトは、常に正の整数でなければならない数量と、品名と価格を持たなければならない。したがって、不変量の実施はドメインエンティティ(特に集約ルート)の責任であり、エンティティオブジェクトは有効でなければ存在できないはずです。

  • ドメインビジネスロジックを格納します。可能な限りビジネスロジックをサービスに入れることは避けましょう。これは貧弱なドメインモデルにつながります(ドメインサービスは、単一のエンティティに入れることができないビジネスロジックのための例外です)。
  • それを定義し、他と区別できるようなアイデンティティを持つ。そのアイデンティティは、ライフサイクルを通じて一貫していなければなりません。
  • 2つのエンティティの等価性は、その識別子(通常はそのidフィールド)を比較することで決定されます。
  • 他のエンティティやバリューオブジェクトなど、他のオブジェクトを含むことができます。※adr3 NestJS頑張らないDDDではvalue objectは実装の簡略化のため使わない
  • ドメインがどのように変化していくのか、その理解を一箇所に集める役割を担っています。
  • 所有するオブジェクトのオペレーションを調整する役割を担っています。
  • 上位レイヤー(サービス、コントローラなど)については何も知りません。
  • ドメイン・エンティティのデータは、データベース・スキーマではなく、ビジネス・ロジックに対応するようにモデル化されるべきです ※adr4 NestJS頑張らないDDDではほとんどの場合テーブルと同じプロパティを持つことになるでしょう。
  • メソッドを使用して状態を更新し、必要に応じて更新ごとに不変性検証を実行します(これは、更新によってビジネスルールが侵害されないかどうかをチェックする単純なvalidate()メソッドでかまいません)。
  • 作成時に一貫性がなければなりません。作成時にエンティティや他のドメインオブジェクトを検証し、最初の失敗でエラーを投げる。高速に失敗する。
  • 引数のない(空の)コンストラクタを避け、コンストラクタ内(またはcreate() などのファクトリメソッド内)で必要なすべてのプロパティを受け入れ、検証します。
  • Entityを部分的にイミュータブルにする。作成後に変更してはいけないプロパティを特定し、それらを読み取り専用にします(例えばidや createdAt)。

アグリゲート

集約は、単一のユニットとして扱うことができるドメインオブジェクトの集合です。概念的に一緒になっているエンティティやバリューオブジェクトをカプセル化します。また、それらのドメインオブジェクトを操作することができる操作のセットも含んでいます。

  • 大きすぎる集約Rootは、パフォーマンスやメンテナンスの問題につながるので、なるべく避けましょう。
  • AggregateはDomain Eventを公開することができます(詳細は後述)。

つまり、複数の関連エンティティとバリューオブジェクトを1つのルートエンティティ内にまとめると、このルートエンティティは Aggregate Rootとなり、この関連エンティティとバリューオブジェクトのクラスタはAggregateとなります。

NestJS頑張らないDDDにおいては、次の認識で運用します。一つの集約は強い整合性を持っていること。一つの集約は、最低一つのエンティティを持っていることです。ジェネレーターでコード生成することもあり、1テーブル、1エンティティ、1集約になりがちです。それで不都合がある場合でパフォーマンス的に問題ない場合に複数テーブルを一つの集約で管理します。

ドメインイベント

ドメインイベントは、あるドメインで何かが起こったことを示し、同じドメインの他の部分(インプロセス)に知っていてほしいことを示す。ドメインイベントは、メモリ内のドメインイベントディスパッチャにプッシュされるメッセージに過ぎません。 ※adr5 NestJS頑張らないDDDにおいては、ユースケースレイヤーを明確するためにドメインイベントをインプロセスでは使用しません

例えば、ユーザーが何かを購入した場合、次のようなことが考えられます。

  • ショッピングカートを更新する。
  • 彼の財布からお金を引き出す。
  • 新規出荷の注文を作成する。
  • buy "コマンドを実行するアグリゲートには関係ない、他のドメイン操作を実行する。

一般的なアプローチでは、このロジックをすべて「購入」操作を行うサービスの中で実行することになります。しかし、これでは異なるサブドメイン間のカップリングが発生してしまいます。

別のアプローチとして、ドメインイベントを発行することもできます。1つのアグリゲートインスタンスに関連するコマンドを実行すると、1つまたは複数の追加のアグリゲート上で実行される追加のドメインルールが必要になる場合、ドメインイベントによってトリガーされるようにそれらの副作用を設計して実装することができます。同じドメインモデル内の複数のアグリゲートにまたがる状態変化の伝播は、具象ドメインイベントをサブスクライブして、必要な数のイベントハンドラを作成することで実行できます。これにより、アグリゲート間のカップリングが防止されます。 ※adr5 NestJS頑張らないDDDにおいては、複数のアグリゲートにまたがる状態変化を一つのユースケース、一つのトランザクションで表現します。

ドメインイベントは、各イベントをデータベースに保存することで、重要なエンティティへのすべての変更を追跡する監査ログを作成するのに便利です。監査ログが有用な理由については、こちらをご覧ください。ソフト削除が好ましくない理由と、その代わりに何をすべきかを説明します。

1つのプロセスで複数のアグリゲートにまたがるドメインイベントによって引き起こされたすべての変更は、1つのデータベーストランザクションに保存することができます。このアプローチにより、データの一貫性と整合性が保証される。トランザクションでフロー全体を包むか、Unit of Workなどのパターンを使用すると効果的です。 トランザクションを乱用すると、複数のユーザーが同時に1つのレコードを変更しようとしたときにボトルネックが発生する可能性があることに留意してください。トランザクションは余裕のある場合にのみ使用し、そうでない場合は他のアプローチ(最終的な一貫性など)を取るようにしましょう。

※adr6 NestJS頑張らないDDDではドメインイベントはcloud pubsubによってパブリッシュします。

Mapper

Entityをシリアライズ、デシリアライズするための役割です。

  • データベースに保存するときにtoPersistenceを呼んで、レコードにマッピングする。保存前に必ずバリデーションを実行する。
  • データベースからアプリケーションのEntityに変換する。toDomainを呼んで、レコードにマッピングする。
  • エンティティをレスポンスに変換する。

実装する際の考え方ガイド

ここからはより詳細に雰囲気がつかめる様にに重要なユースケースについて補足を入れていきます。

ユースケースの実装観点

  • 抽象化は運用してから考える。初手で抽象化はしない。
  • 処理のフローを思い浮かべてそれを素直に実装する。(データの取得、データの登録、エンティティの作成、エンティティの操作、トランザクションロールバック、テーブルのロックなどが該当する。)
  • ユースケースに沿ったメソッド名をエンティティやリポジトリなどに命名して実装を行う。

シーケンス図とコード

ユースケースは、抽象化をすることなく主だった処理をコードの処理の順番と一致させるようにすることで 見通しの良い、わかりやすいユースケースになる。

例えば、次の事例について考えます。

ストライプを決済手段として取引を始める。

ユースケースは、「取引を開始する(全ての決済手段を抽象化してハンドリングする)」という抽象化された実装ではなく、 具体化した「ストライプを決済手段として、取引を開始する」というようなユースケースを実装していくことを心がけます。

具体化したユースケースを見つけだしで実装する理由
  • 抽象化は難しいです。抽象化ありきで考えるのではなく、運用してみて抽象化した方が良さそうとなった時に抽象化できるようにテストを書いておく方が効率的なことがほとんどです。

モジュールの実装観点

  • moduleはリポジトリごとに構築する。
  • 外部サービスの呼び出しもmoduleとして切り出す。

moduleとしては、再利用性の高い単位で切り出しておいて、複数のmoduleから呼び出される前提で実装します。 外部サービス呼び出しが何処かに分散していると、どんな用途での呼び出しがあるのかわかりにくくなるので呼び出す外部サービスごともしくはデータベースごとにmoduleを分割します。 さらに、今回はRepositoryが再利用性の高いmoduleになるのでさらに分割します。

  • module/stripe // stripeのサービスの呼び出し部分
  • module/stripe_invoice // stripe_invoice Repository
  • module/stripe_payment // stripe_payment Repository
  • module/deal // deal Repository

として、dealでstripeとstripe_invoice必要であればimportします。

ArchitectureDecisionRecord

ここから、ADRのお話です。それってどうなのってことありましたらコメントください!

adr1 Applicationサービスに多少ドメイン知識が入っても許容する。

NestJS頑張らないDDDにおいてはドメインサービスを導入しない予定なのである程度は許容)

ドメインサービスとは何なのか、ドメイン知識がユースケースに流出するとはどういうことか。

ドメインサービスとは何なのか

こちらの方の記事がすごくわかりやすいので読んでみてください。ありがたい。

要点は、その複数の集約の状態が変わること場合でかつ、その状態の変更に具体的な名前がつく場合に実装することがあるということです。 ドメインサービスを実装してみたい場合は、ぜひ挑戦してほしい。 なくてもメンテナンス性がそこまで損なわれることはなさそうなので、ドメインサービスありきでガチガチに実装するという選択肢は取らないというくらいの認識でいてほしいです。

ドメインサービスの実装例 引用

@RequiredArgsConstructor
public class ContractDomainService {
    // 契約書のリポジトリインタフェース
    // フレームワークによってDIされる
    private final DocumentRepository documentRepository;
    // 契約のリポジトリインタフェース
    private final ContractRepository contractRepository;

    /**
     * 契約書による契約更新
     */
    public void renewContract(
            DocumentId documentId,
            DocumentPartyId documentPartyId,
            RenewDateRange renewDateRange) {

        // 集約の復元
        Document document = documentRepository.findById(documentId);
        Contract contract =
                contractRepository.findById(document.getContractId());

        // 契約書の当事者(DocumentParty)が押印する
        document.signBy(documentPartyId);

        // 契約期間を引数の期間で更新する
        contract.renew(renewDateRange);

        // 状態を変えた集約の永続化
        documentRepository.save(document);
        contractRepository.save(contract);
    }
}

adr2 NestjsのCommand Busは使用しない。

コマンドを実行するには、サービスを直接インポートする代わりに、Command Busを使うことができます。これにより、コマンドのInvokerとReceiverが切り離されるので、カップリングを作ることなく、どこからでもコマンドを送信できるようになります。

これをすることで、コマンドさえ送られたらそれをサブスクライブしているコマンドハンドラーが実行されます。 NestJS頑張らないDDDにおいては、実装初期にそこまで複数のプロトコルから(入力を受け取ることを想定していない) 必要になったら導入してもいいとは思ってます。

CommandBusを入れるとCommandBusによってInvokerとReciverが切り離されてコードジャンプでCommandHandler実装までの一気に辿り着かないので、それも導入に至らなかった理由の一つです。

adr3 NestJS頑張らないDDDではvalue objectは実装の簡略化のため使わない

NestJS頑張らないDDDにおいては、value objectありきで実装は行わないくらいの認識でいてほしいです。すべてのプロパティをvalue objectにするのは当然やりすぎ感がある。 挑戦したいと言った文脈やvalue objectあった方がこの場合は便利と判断できる場合は導入してみてほしいとは思っています。

ためしに1つのエンティティの一部をvalue objectにしてみるのであれば影響範囲も少ないので気軽に良いのではないでしょうか?

adr4 ドメイン・エンティティのデータは、データベース・スキーマではなく、ビジネス・ロジックに対応するようにモデル化されるべきですは割と無視

ドメイン・エンティティのデータは、~~データベース・スキーマではなく、ビジネス・ロジックに対応するようにモデル化されるべきです

この原則を割と無視しています。

テーブルがモデリングをベースにしているので、エンティティのデータがテーブルと1対1になっても良い認識です。 コードを書きたくない精神でprisma-zodというのを導入しているのとそれを前提にしたジェネレータを作成しているので(また別の記事で紹介します。)、テーブルと1対1を推奨しています。

テーブルから自動生成されるzod

import * as z from "zod"
import { StripePaymentStatus } from "@prisma/client"

export const StripePaymentZod = z.object({
  id: z.string().uuid(),
  userId: z.string(),
  stripeCustomerId: z.string(),
  stripePaymentMethodId: z.string(),
  status: z.nativeEnum(StripePaymentStatus),
  fingerprint: z.string().nullish(),
  funding: z.string(),
  country: z.string(),
  currency: z.string(),
  brand: z.string(),
  expMonth: z.string(),
  expYear: z.string(),
  last4: z.string(),
  createdAt: z.date(),
  updatedAt: z.date(),
})

自動生成しているzodを使って作っているEntity独自コマンドを作って、コピペになりがちな所は自動生成しています。RepositoryとEntity、Command、CommandHandler、Mapperといった一連のファイルを自動生成します。

export type StripePaymentModel = z.TypeOf<typeof StripePaymentZod> // 自動生成のzodからtypeを取得する。

type StripePaymentProps = Omit<StripePaymentModel, 'id' | 'createdAt' | 'updatedAt'>

type CreateStripePaymentProps = Pick<
  StripePaymentProps,
  | 'userId'
  | 'stripeCustomerId'
  | 'stripePaymentMethodId'
  | 'fingerprint'
  | 'funding'
  | 'country'
  | 'currency'
  | 'brand'
  | 'expMonth'
  | 'expYear'
  | 'last4'
> // 作成時に必要なプロパティは機械的に決められないので、自分で書く。(ただコードアシストはあるので非常に楽)

export class StripePaymentEntity extends AggregateRoot<StripePaymentProps> {
  protected readonly _id: AggregateID

  static create(create: CreateStripePaymentProps): StripePaymentEntity {
    const id = v4()
    const props: StripePaymentProps = { status: 'ACTIVE', ...create }
    const entity = new StripePaymentEntity({ id, props })
    return entity
  }

このように、データベースのテーブルをコードに書き起こすことをほぼしなくてもentityを半分自動生成実装できるようになっています。

自動化することで、柔軟性が亡くなるかと思われる方がいるかもしれませんが、zodスキーマ自体は後から拡張も可能です。それにprismaスキーマにコメント形式で独自のzodスキーマを使用する様にも宣言できます!(こちらも詳しくは実装編で記載します。)

adr5 NestJS頑張らないDDDではドメインイベントをインプロセスでは使用しない

ドメインイベントをインプロセスで実行したいのは、複数の集約の更新をしたい時です。 例えばユーザーを登録したら、自動的にWalletも作られるというような場合です。 NestJS頑張らないDDDにおいては、ユースケースで複数集約の更新を許容している(実装のシンプルさと速さを優先している)ので不要と判断しました。

adr6 NestJS頑張らないDDDではドメインイベントをCloudPubSubによってpublishする

ドメインイベントをプロセス外で実行したいケースは非常に多いです。

  • データのレプリケーションマーケティング用のサービスやDWHに登録する)をストリーミング処理で行いたい
  • 同期的に全ての処理を行うとパフォーマンスが悪化する場合(メール送信、プッシュ通知)
  • 特定のイベントを実行することで再実行したいような場合(メール送信がメールサーバーでエラーだったので再実行したい時にドメインイベントを再度実行するなど)
  • 他のマイクロサービスにデータを登録する場合

adr7 依存性の逆転の廃止

レイヤードアーキテクチャではインターフェースを使って、Repositoryやその他いろんなものを依存性の逆転をして表現することがあります。 それによって実現したいのは、

  • レイヤーの内側は外側のことを知らない(dbをpostgresからmysqlへの変更、データベースのクライアントの変更が用意になる)
  • テスタビリティが高くなる

などです。

個人的な意見

  • そもそもデータベースを変えたいなんてことはほとんど起きないので起きないことまではカバーしなくて良い。
  • NestJSはDIコンテナーを使用しているのでインターフェースがなくてもテスト用にInmemoryのRepositoryを注入することができるはず。
  providers: [
    {
      provide: CatsService,
      useValue: mockCatsService, // ここを MODE == "test" ? mockCatsService : catsService
    },
  ],

このシンプルな処理を抽象化するのも、抽象化したコードを読むのも難易度高い。

export class MakePaidCommandHandler {
  constructor(
    protected readonly dealRepo: DealRepository,
    private readonly prisma: PrismaService,
    private readonly stripeInvoiceRepo: StripeInvoiceRepository,
    protected readonly mapper: DealMapper,
  ) {}

  async execute(command: MakePaidCommand): Promise<DealResponseDto> {
    ...省略
    try {
      await this.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
        await this.stripeInvoiceRepo.update(tx, stripeInvoice)
        await this.dealRepo.update(tx, deal)
      })
    } catch (error) {
      throw error
    }

mock化するのはユニットテストのためで、インターフェースを使ったらそのユニットテストの実装はすごくやりやすい。 ただ、モック化したユニットテストユースケース層の挙動が保証できることがほとんどない。だいたい、結合テストがないと動作の保証ができないのでユニットテストを書かないことの方が多いです。

名誉のために言いますが、実装が複雑で実装できなかったというわけでは決してありません。ち、ちがいますよ!!!

adr8 transaction用に注入されているprismaでデータの呼び出しは原則しない。

下記の箇所に注目

await this.prisma.$transaction

これはprismaをそのまま使用しているので何でもできてしまうインスタンスです。

次に記載するように何でもできます。

this.prisma.user.findUnique ... // 取得処理
this.prisma.user.updateMany ... // 更新処理

原則避けた方がいいと思っています。そのまま呼び出したい場合、何をしたいのかという情報が欠落してしまいます。 それではユースケースの処理が追いにくくなってしまいます。

たとえば、次のように考えます。

例1)UserはすでにUserRepositoryが構築済みであれば、そのUserRepositoryに適切なメソッドを使ってUserEntityを取得すること

this.userRepo.findActiveUserById()
this.userRepo.findBlockingUsers()

例2)UserはUserRepositoryがなくmoduleとしてuserディレクトリも切られてない場合は、

  • 1.module(userを作成する。)
  • 2.moduleの中でUserQueryServiceのように取得に特化したサービスクラスを返す
  • 3.userQueryServiceに適切なメソッド名を使用して呼び出す。
this.userQueryService.findActiveUserById()
this.userQueryService.findBlockingUsers()

adr9 NestJS頑張らないDDDにおいては、エンティティの内部の情報をResponseDtoにマッピングして返すことを許容する。

新規でデータをテーブルに作成した場合などにidはnestjs内で作成されるため、そのIDなどをフロントに返したりしたいためです。 EntityAを更新した場合、そのEntityAの情報を返したり、複数件更新した場合に複数件の情報を返すのはいいけれど、 関連しないテーブルの情報を取得して返すなどは可能な限り避けたいところです。

adr10 TableAを更新できるリポジトリは一つしか作らない。

EntityはTableをマッピングしたデータ構造になります。書き込む時は、エンティティをそのまま書き込むことによって、データの整合性を担保しています。

言い換えたら、該当するEntityを更新できるRepositoryが複数あったり、Repositoryを経由せずに対象のテーブルの更新が入るとデータの整合性が崩れる可能性があります。

なので、実装する際にはEntityを作ってそれを保存するRepositoryが存在する場合、それ以外の場所からの更新は避けた方が無難です。

※障害の対応とかでどうしても直接テーブルにupdateをかけたい場合は、その周辺ロジックに詳しい人と一緒に対応するなどはありだと思います。

まとめ

お疲れ様です。ここまでがNestJS頑張らないDDDのドラフトとなります。 私の中でDDDは複雑なドメインをソフトウェアで開発するためのものと思っています。

ただあくまで手段であることとも忘れてはいけません。 参加するエンジニアの習熟度、オンボーディングの容易さ、品質など複数の要素を考慮してそのプロジェクトに適した形で導入していきたいです。

今回は本番でも使えてかつ、ジュニアクラスのエンジニアでもすぐ実装できる様にドラフトを組んでみました!


👇 aisaac に興味をお持ちの方は、こちらの記事もぜひ。

tech.aisaac.jp

note.com

note.com

note.com

👇 コーポレートサイト、最近リニューアルしました 💯

aisaac.jp