🦁

Post-Renaissance Angular Architecture 2024

2024/12/16に公開

「Angular Renaissance」と言われて、約 1 年が経ちました。この 1 年で Angular は急速な進化を遂げ、それに伴い、皆さんのアプリケーション設計にも変化が生じたことでしょう。この記事では、私が業務や趣味のアプリケーションで試行錯誤を重ねながら考えた Architecture を、実際のアプリケーションを例に解説します。

こちらはAngular Advent Calendar 2024 16 日目の記事です。

対象

必ずしもこの Architecture で作るのが良いわけではありません。今回の Architecture を適用する対象のアプリケーションは以下のようなものを想定しています。要は、企業で開発されるものと考えていただければ OK です。

  • 複数人で長期的に開発する
  • 開発者は途中で出たり入ったりする
  • 新規機能の作成だけでなく、既存機能に対する機能追加・仕様変更・バグ修正などがある

ルール

  • @angular/cli@19 で ng new したプロジェクトを元に作成する
  • SPA
  • developer preview の機能は使わない
    • ただしどうしても effect を使わなきゃいけない場合は使って良い

サンプルアプリケーション zootter

zootter は、動物たちが主役の SNS

GitHub

screenshot

動物たちのツイートが見られる twitter like なアプリを作りました。

この記事を投稿した時点では全然機能が足りてないのですが、Architecture の説明には十分な出来になっているはずです。(本当は post したり、post を編集・削除できるところまで行きたかったんですが、間に合いませんでした汗)
GitHub Pages にデプロイしてあるので、上記レポジトリの README のリンクからアクセスできます。

app/

app/ 内の全体像は以下のようになっています。

appのディレクトリ構成

Eager と Lazy

app の構成要素を大きく2つに分けるのが EagerLazy です。これはディレクトリがあるわけではありませんが、以下に解説するディレクトリどこにファイルを配置するかの指標になります。
Eager が初期バンドルに含まれるもの、Lazy が遅延ロードされるものです。

core/

アプリケーション全体に影響する処理や、アプリケーション初期化時に必要な処理をまとめたディレクトリです。 Eager に属しています。

  • 全体に影響する
    • interceptor
    • guard
    • errorHandler
  • アプリケーション初期化もしくは app.component.ts から使われる
    • auth
    • usecases

api/

アプリケーションと外界とを繋ぐ窓口です。主に HttpClient を使って API を叩く関数や、ブラウザのストレージとのやりとりをする関数が入っています。
zootter では HttpClient を模したような関数の集まりになっています。本当は mock server とか作りたかった涙

domain/

TypeScript の関数や型、定数の宣言が含まれます。ここに属するものは基本的に Angular 関連の import をしません。たとえフロントエンドフレームワークを変更したとしてもそのまま使うようなドメインロジックや、API の型・定数などを入れておきます。

shared/

アプリケーション全体で使う component / directive / pipe などを入れておきます。

app.routes.ts

route の設定です。後述する各 feature の route.ts を登録します。

app.routes.ts(例)
export const routes: Routes = [
  {
    path: 'login',
    loadChildren: () => import('./features/auth/auth.routes'),
  },
  {
    path: 'home',
    loadChildren: () => import('./features/home/home.routes'),
  },
  ...
];

features/

アプリケーションの各機能が入っています。zootter でいうと login / home / list / profile のような、URL でいうとドメインの次の path で区切られる機能の括りです。
feature は複数の page と 1 つの routes.ts から成り立っています。

page/

1 つの page component と複数の unit から成り立っています。(この unit 命名は正直しっくり来てません。いいアイデアがあったら教えて下さい!)

pagesのディレクトリ構成

page component

URL と一対一になる component です。主に URL のパラメーターを受け取って unit に渡したり、layout に合わせて unit を配置する役割を担います。page の layout は割と同じになることが多いです(1 カラムレイアウト、2 カラムレイアウトなど)。書いてもそんなに多くないスタイルの量ではありますが、shared/layout の中に layout 用の component を作成して page component で使います。(zootter では作るのを忘れたので投稿時点でありません汗)

unit

1 つの container component を中心に作られる 1 機能です。

unit構成

container component

ハブとなる component です。

template / style

template には view component や shared/ui の component を並べます。基本的に self closing tag のみ を使い、css は flex の設定のみになるよう構成します。

store.state

store から state を読み取って view component の input に渡します。このとき state の加工はせず、そのまま view component に渡しましょう。container component で加工が必要だと感じたときは、store か view component のどちらかでできないか考えましょう。

usecase.execute()

component の Lifecycle や view component の output を起点に usecase を呼び出します。

view component

見た目を構成する component です。container component から input を受け取って画面に表示し、user action を container component に output します。基本的には何も inject しません。

再利用性

container / presentational component の文脈では、presentational component は UI と ロジックを分けることで再利用性が向上するというメリットが挙げられることが多いですが、この view component は再利用を考えません。過去に再利用を考慮して input / output を構成していたのですが、ほとんどの view component は再利用されることがないからです。再利用される汎用性の高い component は shared/ui に作成しましょう。

usecase

ユーザーの 1 操作につき、1 つの usecase となる複数の service から構成されています。一つの execute 関数を持ち、それ以外は private にします。こうすることで機能追加・削除・修正などがとても容易になっています。主な役割は API を叩いて結果を store に格納することです。

execute 関数

component -> usecase -> store -> component の一方向の流れを崩さないよう、execute 関数は戻り値を持たないことに注意してください。どうしても戻り値が欲しくなった場合は、引数に callback 関数を受け取りましょう。

index.ts

複数の usecase をカバーする container component では、usecase がとても増えてしまいます。そうなると container component で provide や inject をたくさん書かなければならないので、index.ts を用意して provide と inject が一気にできるようにします。

usecases/index.ts
export function provideUsecases() {
  return [InitUsecase, LoadMoreUsecase];
}

export function injectUsecases() {
  return {
    init: inject(InitUsecase),
    loadMore: inject(LoadMoreUsecase),
  };
}

store

unit の状態を複数の signal を使って管理します。private な signal と更新用の関数、公開用の読み取り専用 state で構成されています。
API の response を格納することが多いですが、意識して API の型を使わないようにします。アプリケーションの状態として何を管理すべきか考えることが重要なのと、API の response の型が変更されたとき、影響を usecase までに留めるためです。

list-info.state.ts
@Injectable()
export class ListInfoStore {
  private listInfo = signal<ListInfo>(skeletonListInfo);
  private loading = signal<boolean>(false);

  setListInfo = (listInfo: ListInfo) => this.listInfo.set(listInfo);
  loadingStart = () => this.loading.set(true);
  loadingEnd = () => this.loading.set(false);

  state = {
    listInfo: this.listInfo.asReadonly(),
    loading: this.loading.asReadonly(),
    isReady: computed(() => this.listInfo().id !== '' && !this.loading()),
  };
}

まだ解決していない問題

interface

unit 内で使う interface をどこにどう定義するかフワフワしてます。ここはまだ考えが足りていません。

form 関連

今回 zootter では form を実装するところまで至りませんでした。この辺ももう少し考えたいです。近々 post 機能や profile 編集機能、動物追加機能なんかを実装する上で考えがまとまったら共有したいと思います!

あなたの Architecture も知りたいです!!

これをここまで読んでくださったあなた!きっと Angular の Architecture に興味がお有りでしょう。ぜひ zootter を fork してあなたの Architecture の題材にしてみてください。お正月休みなんかにね、コードいじってもらって、あなたの自慢の Architecture も教えて下さい!!
わたしももっと作りたい気持ちがありますし、まだまだ手の行き届いていない部分がたくさんあるので、issue を立てていただくのも大歓迎です。お待ちしております。

それでは当記事はこの辺で、明日は @nao_y さん よろしくお願いしまーす!

GitHubで編集を提案

Discussion