Upgrade to Pro — share decks privately, control downloads, hide ads and more …

APIの意義と向き合い チームで成長した軌跡

Avatar for 8zca 8zca
June 22, 2025
34

APIの意義と向き合い チームで成長した軌跡

2025/06/18 現場.ts で発表したスライドです。

チームのほとんどのメンバーがTypeScript初挑戦!
スペースマーケットのバックエンド基盤をRailsからNestJSへ移行するためにチームで取り組んだこととは⋯?

Avatar for 8zca

8zca

June 22, 2025
Tweet

Transcript

  1. Who? 森脇 和也 2023年11月にスペースマーケット入社。 前職では Rails メインに開発をしていた rubist です。 スペースマーケットで TypeScript

    での開発をがっつり行うようになりました。 現在は基盤チームで バックエンド刷新(Rails to NestjS)を鋭意進行中です 🚄 最近 checker.ts の沼にハマってました。 3 https://github.com/8zca
  2. 移行の経緯と課題 • 複数あるRailsアプリケーションのうち一部をNestJSに置き換えて、 変更容易性を確保しようというところから始まった • 3年ほど Now → ToBe の状態のまま進まず⋯

    😢 ◦ 移行自体も停滞してしまっていた • 保守対象のシステムが1つ増えた状態に • Railsのコードは10年分の施策が積み重なっており⋯ 8 漂う停滞感、保守のつらみ
  3. モバイルアプリの開発チーム(3名)がジョイン! • 専門はモバイルアプリ開発 • バックエンド開発の経験 → 少ない • TypeScript → はじめて • NestJS → はじめて •

    クリーンアーキテクチャ → はじめて はじめてづくしの彼らと、どのようにチーム・プロダクトを作り上げていくのか。 そしてCTOともう1名のリードエンジニアは別施策へ⋯ わたしたちのチームビルディングと知識共有の新たな挑戦が始まった。 12 \こんにちわ/ \重要施策やります/
  4. 取り組み① 型と責務のインストール • NestJSやクリーンアーキテクチャの構成・考え方をインストール ◦ 目指したい姿の共有(なぜやっているのか) ◦ NestJSのモジュールシステムとは ◦ クリーンアーキテクチャと各層の責務

    ▪ application, domain, infrastructure が持つ責務とは ▪ contextの役割とは • スペース情報、予約情報、ユーザ情報など複数のコンテキストから成り立つよう にしていた ▪ これを採用すると何がうれしいのか 13
  5. 取り組み② 実践!GraphQL Resolver 一本をリファクタリング • 習うより慣れろの精神で、モブプロを開催 • 一本完了するまで、1日3〜4時間を合計4日ほど実施 • ドライバーを交代しながら実施

    ◦ 初回だけ私 → その後は3名の中で順番に担当 ◦ このようにコードを書く理由や思考を話しながら進めていたが、自然にみんなやるようにな り、終盤では議論できる状態に ▪ 元々KotlinやSwiftに慣れていたメンバーのため型のある言語に対して理解が速かった ▪ また、モバイルアプリスクラムチームとして、対話を通して自ら改善の施策を考える など「自分たちで考えて動く」という文化が形成されていたことも大きく寄与 14
  6. 新卒1名もこのチームにジョイン! • モバイルアプリチームの配属予定だったことでMXチームへ • バックエンド開発の経験 → 少ない(研修でやった) • TypeScript → はじめて • NestJS → はじめて •

    クリーンアーキテクチャ → はじめて 第二の形成期を迎えたが、取り組んだことは同じ。 モブプロをペアプロに変え、①〜③を実施。今ではある程度のAPIなら一人でこなせるように。 18 \絶賛リファクタリング中/ \重要施策開発中/ \おねがいします!/
  7. Before After: リポジトリパターン 21 @Injectable() export class FooRepository { //

    usecaseが入り込んだ実装 async get() { // 条件Aで取得 const results = await this.repository.createQueryBuilder(...).where(...).limit(this.NUM) if (results.length === this.NUM) return results if (results.length === 0) { // 条件Aで取れなければ別条件でとる ... } // それでも足りなければ.. ... } } @Injectable() export interface FooRepository { findByDate(args: { take: number; date: Date }): Promise<Foo[]> findExcludeIds(args: { ids: number[]; take: number }): Promise<Foo[]> } Before After 異なる条件(優先度)があり、 条件AだけでNUM件埋まるならそのまま 足りない場合は条件変えて埋める 条件の指定や組み立ては usecase 側で行い、 リポジトリはあくまで汎用的に使えるように (メソッド名も汎用的に)
  8. Before After: タグ付きユニオン 22 // スペースを取得するときの入力(いずれか必須) export type SpaceArgs {

    id?: number uid?: string friendlyId?: string // RubyのGem由来のid } export type SpaceArgs { readonly id: number readonly kind: 'id' } | { readonly uid: string readonly kind: 'uid' } | { readonly friendlyId: string readonly kind: 'friendlyId' } Before After いずれもoptionalなため、指定がない場合 や、idとuidを指定した場合にどうなるのか想 像がつきにくい タグ付きユニオンにすることで、取れるidの 種類が明確になり、型の絞り込みが効くよう に
  9. Before After: ドメインオブジェクトの振る舞い 23 @Entity() export class Space { @Column()

    id: number // フィールド定義 // ... } export class Space { id: number // 他に必要なフィールド // ... isAvailable(): boolean { // 利用可否の判定 return this.status === 1 && ... } } Before After カラム定義だけされたエンティティ。 スペースが利用可能かどうかは service クラ スの判定メソッドを利用する。 ドメインの知識がSpaceクラスに移ったこと で表現力が向上。 ドメインモデル貧血症からの脱却。 ※リファクタリング後もデータクラスになっ ているエンティティが存在しています。。 ※特に触れていませんでしたが、TypeORM → Prismaに変更してるため、 Beforeは TypeORM由来のコードがあります。
  10. Before After: コンストラクタ注入 24 export class City { setData(args: {

    // データ }) { this.id = args.id // ... } } export class City { constructor(args: { // データ }) { this.id = args.id // ... } } Before After セッターでプロパティを更新でき、ミュータ ブルなオブジェクトになっていた。 コンストラクタで必要なデータを注入し、 readonlyとあわせてイミュータブルな状態を 作るようにした。 ただしentityなどライフサイクルがあるもの はこの限りではない。
  11. satisfiesにより補完を効かせつつ型の拡大を防ぐ 25 // このテーブルは特定のフィールドしか使わないので select すべきカラムを共通化 const fields: Prisma.ReputationsSelect =

    { toId: true, fromId: true, score: true } @Injectable() export class PrismaUserReputationRepository implements UserReputationRepository { async findByUserI(userId: number): Promise<UserReputation[]> { // この結果には ReputationsSelect にある他のフィールド も含まれてしまう const results = await this.#prisma.reputations.findMany({ where: { 条件 }, select: fields, }) return //... } } const fields = { toId: true, fromId: true, score: true } as satisfies Prisma.ReputationsSelect @Injectable() export class PrismaUserReputationRepository implements UserReputationRepository { async findByUserId(userId: number): Promise<UserReputation[]> { // toId, fromId, score の配列がとれる const results = await this.#prisma.reputations.findMany({ where: { 条件 }, select: fields, }) return //... } } Before After
  12. • Effective TypeScriptの輪読会を木曜朝に開催 • 先程の satisfies はこの書籍ではじめて知った • 型の拡大やジェネリクスの使い方、条件型のユニオン分配など詳しく学べる一冊でおすすめ TypeScript

    力アップの試み 27 事前に決められたパートを読んで学びやわからな かったことを付箋にアウトプット。 当日、共有したり議論したり。
  13. しくじり • 影響範囲を見誤り、消してはいけないAPIを削除 ◦ webやアプリのクライアントではなく別のAPIサーバから呼ばれていたこと&githubで organization検索したときに折りたたまれていた中に該当のコードがあった • strict: false を

    true にした影響 ◦ 既存コードから「id: number」で渡ってきたものは nullable の可能性がある ◦ strict: true では null チェックが厳密にされるため、null で渡るケースを閉じてしまった ▪ null も許容する必要があった 29