マイクロサービス化を中心においた技術刷新とその狙い

本文

はじめに

 レバレジーズ株式会社 テクノロジー戦略室室長の竹下です。レバレジーズは創業以来事業の成長が続いていますが、ここ最近は事業の拡大にシステムの開発が追いつけていない状態でした。そのため、システム開発が事業の拡大に追いつき、更には加速させるためにマイクロサービス化を主体においた技術スタックの刷新を進めています。この記事では、導入したまたは導入しようとしている主な技術の紹介と、選定した技術が目先の開発だけではなくエンジニア組織全体の課題をどのように解決するかを紹介していきたいと思います。

導入技術紹介

 まず、現在導入したまたは導入しようとしている主な技術のご紹介から始めます。

  • マイクロサービス化
  • レイヤードアーキテクチャ(クリーンアーキテクチャ、ヘキサゴナルアーキテクチャ)
  • DDD
  • TypeScript
  • gRPC
  • gRPC-web
  • GraphQL
  • Github actions
  • BDD/ATDD(cucumber)
  • コード生成
  • CDK
  • Docker
  • ECS Fargate
  • Service Mesh
  • DataDog

列挙した技術がざっくりどの領域に関係するかの図はこちらになります。

 こちらに紹介しているのは主要な技術のみで、他にも様々な技術検証、導入を進めています。それらはこれからの弊社の技術者ブログを楽しみにしていただければと思います。また、個別の技術の紹介もこちらの記事では詳しく行いませんので、あしからず。

事業成長にシステムが追いつくための課題

 それでは、本題の技術導入で解決したい課題を説明していきます。
 まず弊社の開発体制ですが、基本的には1サービス毎にチームが付き、サービス毎にコードベースを分けて開発を行ってきました。
 レバレジーズ主力事業である『レバテック』は、サービスを使ってくださるエンジニアの方や、企業様が年々増えており順調に成長を続けてきました。また、『レバテックダイレクト』や『レバテックカレッジ』をはじめとするレバテックシリーズの新サービスや、看護師転職支援サービス『看護のお仕事』、ITエンジニア向けQAサイト『teratail』などのサービスも提供をしてきました。しかし、開発サイドでは、長年機能とビジネス範囲の拡張を続けたことによるコードベースの肥大化や、サービス間のデータ連携のために本来独立しているはずのチームが密に結合してしまい、人を増やすだけでは事業成長に追いつけなくなってしまいました。特に

  • 相互依存する、複数の巨大モノリシックサービス
  • 標準化の欠乏
  • 自動化の欠乏

の3つによって、組織としての開発効率が下がってしまっていました。

相互依存する、複数の巨大モノリシックサービス

 モノリシックサービスとは、一つのプログラムで一つのサービスを作ることを指します。 弊社もこれまではモノリシックにサービスを開発してきましたが、長年機能拡張を続けてきた結果、PHPで開発してきたことも相まって、コードを一部修正した場合に影響範囲がどこまで及ぶがわかない状態となり、影響の確認のために多大な工数がかかるようになってしまいました。また、サービス間でのデータ連携も行われているため、影響範囲が1つのサービスだけに留まらず他のサービスを巻き込んでバグを出すということも頻発するようになってしまっていました。
 さらに、一つのプログラムが受け持つビジネス領域も拡大してきた結果、必要な業務知識も膨らみ、新しいエンジニアがまともに開発できるようになるまでにも多くの時間がかかるようになっていました。

標準化の欠乏

 弊社では基本的に1サービス1チームが割り当てられ、コードベースも分けて開発を進めています。しかし、これまではコード規約ぐらいしか標準化されたものが無く、開発スタイルや設計、インフラ構築方法もチームでバラバラでした。そのため、スポットで他チームへヘルプに入ったり、ノウハウの共有が困難でした。
 新規サービス開発の際も、機能の流用ができる作りになっていないので、各サービスで同じような機能をバラバラに作るということも多く発生していました。 そのため、チームが増えても相乗効果が発生せず、組織としての開発効率がいまいち上がらない状態になっていました。

自動化の欠乏

 CI/CDが入っていないサービスも多々あり、デプロイやインフラ構築もシェルを手動実行するものが数多く残っていました。そのため、開発面ではチーム毎、開発者ごとにコードの品質が揃わなかったり、運用ではデプロイが限られた人しか実行できなかったり、新しいサービスを作る際にもインフラ構築に工数が多くかかる上に、インフラの監視やログ監視も抜け漏れが発生するなど、開発効率の低下や品質の低下を招いていました。

導入技術がどのように課題を解決するか

 上記の課題を解決するのは一つの技術だけでは解決できないため、マイクロサービス化を中心においた技術スタックの刷新を行い、技術の組み合わせによって解決を目指しました。

マイクロサービス化とレイヤードアーキテクチャを主体とした分割統治

 プログラムの設計において分割統治という考え方があります。大きな問題を小さな問題に分割して、小さな問題をすべて解決することで全体を解決するという考え方です。  サービスのフロント、BFF、バックエンドの3層への分割、ビジネスドメインを再整理しドメインに分割しマイクロサービス化、一つのマイクロサービス内でのレイヤードアーキテクチャによる分割を行っています。それにより、

  • プログラム変更の影響範囲が限定される
  • 開発者が知る必要があるドメイン知識が減る
  • ユニットテストが容易になる

などのメリットを享受でき、結果、長期に渡り開発効率を高い状態で維持することが期待できます。

TypeScript、gRPC, GraphQLなど型システム技術導入によるチーム間コミュニケーションの円滑化

 マイクロサービス化、レイヤードアーキテクチャ化することでチームは細分化されることになるので、チーム間のコミュニケーションが重要になってきます。そのため、フロントエンド、バックエンドともにTypeScriptへ切り替え、マイクロサービス間はgRPC、フロントエンドとバックエンド間はgRPC-webまたはGraphQLを導入しました。gRPC、gRPC-web, GraphQLのIDL(interface definition language)が、そのままAPI仕様書となり、IDLからコードを自動生成しコンパイルを行うことで変更点をコンパイルエラーとして検出できるため、仕様変更した際の伝達が容易かつ安全に行えるようになります。

CI/CD、コード生成、BDD/ATDDの活用による品質の担保

 レイヤードアーキテクチャ化によるユニットテストの容易化、TypeScript化によるコンパイルエラーによるエラー検知を活用するためCI/CDとしてgithub actionsを導入しソースコードのPush時に自動で検証できるようにしています。
 TS化、CI/CDでエラー検知が容易なったことでコード生成も最大限活用できるようになりました。gRPCのIDLからのコード生成の拡張によりレイヤードアーキテクチャのためのボイラープレートの生成や、DBアクセスオブジェクトからの安全なSQL発行コードの生成などを導入することで、開発速度の向上に加えコード品質の安定化も行っています。
 一部のチームではユニットテストに加えてcucumberを導入して、マイクロサービスのAPIをBDD(振る舞い駆動開発)、フロントエンドをATDD(受け入れテスト駆動開発)しています。実装前にエンジニア同士やディレクター-エンジニア間で仕様のすり合わせと検討が可能になり、手戻りが減った上に設計品質の向上にも繋がりました。

IaCによるインフラの既製化とDevOps推進

 弊社はAWSをメインに使用しているため、インフラの構築はCDKを導入しました。CDKはTypeScriptやJavaなどプログラミング言語を使ってインフラを構築できる技術です。アプリサーバーの構成がすべて、フロント、BFF、バックエンドの3層化しDocker+ECS Fargateでサーバーレス化したことで、同じインフラ構築のコードをどのチームでも使い回せるようになりました。CDKをラップした社内ライブラリを用意したことで、アプリエンジニアがインフラ構築から行えるようになり、かつ、サーバーの構築が共通化されたことで監視の漏れや設定のミスもなくなりました。
 現在は更にDataDogの導入も行い、インフラ監視、ログ監視などを強化しています。
 また、マイクロサービス化が進み、サーバーも増えてきているため、ServiceMeshも導入を進めており、より緻密で効率的なサーバー管理を目指しています。

便利さによる標準化の浸透

 技術選定したはいいものの浸透しないということもあると思います。大体は新しい技術を覚えたり切り替えることに対する不安が原因なことが多いです。今回選定した技術に関してほぼ全て社内ライブラリを用意し弊社の開発に特化させることで、

新しいことを覚えるコスト <<< 選定された技術を使うメリット

という状況を作り出しました。一つの技術選定だけでは実現は難しいですが、複数の技術をうまく組み合わせその状況を作り出せれば、あとは自己増殖的に浸透してくれます。

おわりに

 レバレジーズは「関係者全員の幸福を追求する」ミッションを達成するため、今回ご紹介したように、開発効率を高め、事業実現を素早く行えるエンジニア組織への変革をすすめています。そのため技術選定も技術的好奇心や目下の開発だけにとらわれない選定を心がけて行っています。しかし、まだまだ改善できるところは多々あります。もし「技術選定を行って組織を変えていきたい」と思っている方がいましたら、ぜひ一緒に働きませんか?エキサイティングな仕事ができると思います。 ご興味ある方は、こちらからエントリーをお願いします! https://recruit.jobcan.jp/leverages/recruit.jobcan.jp

レバレジーズの急拡大を支える内製化SFAとは

はじめに

こんにちは! エンジニアの室井です。

近年、レバレジーズは事業の急拡大を続けていますが、その中核となっている「Dataforce(データフォース)」と呼ばれる自社プロダクトをご紹介します。

Dataforce(データフォース)とは?

Dataforce(データフォース)はレバレジーズが内製化しているSFA※1です。

※1 営業業務の効率化を図り、売上や利益の向上を実現する営業支援システム、Sales Force Automationの略称

Dataforceは現在、看護師の紹介事業・派遣事業、そして介護士の紹介事業・派遣事業の4つの事業で利用されています。

国民の4人に1人が65歳以上という少子高齢社会を迎えた今、医療従事者の需要は日本全体で高まっていますが、依然として十分に供給が追いついておらず、医療・介護業界における人手不足が発生しています。

だからこそ我々SFA開発チームは、Dataforceを通して営業業務の効率化を向上させることで、病院や施設の抱える人手不足問題の解消に貢献することができると考え、日々取り組んでいます。

Dataforceが持つ主な機能は次の3つです。

1. 求職者、事業所管理機能
 求職者、事業所に関する基本情報や接触履歴など全ての情報を統合管理する機能です。 顧客から問い合わせが入ったときに担当者が不在でも、顧客管理画面を呼び出せば相手先の情報がひと目でわかり、担当者以外の人の的確かつ素早い対応が可能です。

2. フロー管理機能
 求職者に求人を紹介をする段階から、面接日程調整・入職までの流れの中で、個々のフローを管理する機能です。 ひとつひとつのフローについて、顧客情報、求人情報、営業フェーズ、入職予定日など、紐づけられた関連情報を俯瞰することができます。

3. 活動管理機能
 営業活動における行動や結果を数値化して管理する機能です。 担当者の案件数、営業実績などの情報を管理・蓄積することができるため、成績の良いメンバーの行動パターンを、他のメンバーに共有すれば、チーム全体のパフォーマンスの底上げにもつながります。

Dataforceの強み

Dataforceには多くの強みがありますが、今回はその中から2つご紹介します。

1. 営業業務に必要な機能の集約
 LINE to Call や Twillo などの外部連携APIを使用してDataforce内でLINE、SMS、電話といった顧客とのコミュニケーションで使用するツールが利用できます。 これまでバラバラに管理・運用していたツールを一元化することによって、データ統制のみならず、営業業務の効率化や生産性向上を実現しています。

2. 営業向けレコメンド機能
 営業業務のサポート機能として、独自のアルゴリズムを基にした求人レコメンド機能があります。この機能は、求職者の希望条件に近い求人をキャリアアドバイザー(営業)へとレコメンドしてくれる機能であり、新人教育や早期立ち上がりなど、さまざまな面で営業組織をサポートしています。

Dataforceの開発チーム

開発チームは、施策の立案から、要件定義 / 仕様設計 / スケジューリングまでを担当するディレクターと、フロントエンドやバックエンドにおけるアーキテクチャ設計 / 実装設計 / 実装 / コードレビューを行うエンジニアで構成されています。

開発手法は、2週間単位でスプリントを設定したオリジナルのスクラム開発で行なっており、スプリントの始まりに工数見積もり会を開き、終わりにKPTを用いてスプリントの振り返り確認をします。

業務内容はエンジニアリングに限らず、基本的に制限がないことから、意欲があれば誰もが色々な業務に挑戦できる組織です。

チームの特徴としてよくあがることが2つあります。

1. 開発サイクルが速い
 スクラム開発体制を採用することで、ディレクターとエンジニアを同じチーム内に配し、コミュニケーションコストを削減することができ、システムを企画・開発するサイクルが速いです。

2. 主体的な人が多い
 チームやシステムをより良くしたいと主体的に動く人が多いことも特徴の一つです。 日頃から改善点がないか探している人が多いので、エンジニアの業務改善なども頻繁に行われています。 エンジニアの業務改善にとどまらず積極的に施策を立案したり、UI/UX改善を行ったりなど、様々なことにチャレンジしています。

技術スタック

■Laravel
 Dataforceを利用する事業が多岐にわたることから、営業業務プロセスを理解しなければ開発ができないのはよくあることです。しかし、それではプロジェクトに参画した人が即戦力として活躍することが難しくなってしまいます。 そのため、Laravelのサービスコンテナの仕組みやパッケージ開発の容易性を活用し、ルールや事業知識がなくても良い基盤を設計し、パッケージをリポジトリに切り分けることで効率的な開発ができるだけでなく、誰でもすぐに即戦力となり活躍することができます。

■AWS
 Amazon TranscribeやAmazon Comprehendを使用して、顧客とのヒアリングなどで得られる情報をシステムに自動入力することで、営業業務を削減したり、リアルタイムで行われる対話から顧客の感情を解析しCX最適化を行うなど、音声分析に取り組んでいます。

【その他利用技術(一部)】

  • 開発言語
    • PHP
    • JavaScript
    • TypeScript
    • Python
  • フレームワーク
    • Laravel
    • Vue.js
  • インフラストラクチャ
    • AWS : EC2,ECS,S3,RDS,ElastiCache,Lambda
  • ミドルウェア
    • nginx
  • DB、検索エンジン
    • PostgreSQL
    • Elasticsearch
  • 構成管理ツール
    • CloudFormation
  • CI/CD
    • CircleCI
    • GitHub Actions
  • 監視ツール
    • Cloudwatch
  • その他ツール、サービス
    • Docker
    • GitHub
    • sendgrid(email delivery)
    • Twillio
    • LINE to Call

おわりに

Dataforceチームのエンジニアとして働くことには2つの魅力があります。

1. エンジニアとして大きく成長できること
 Dataforceチームでは、様々なAPIを用いた開発やレコメンド機能を用いた開発など、色々なプロジェクトに携わることができます。そのため、幅広い経験をすることができ、大きな成長ができます。

2. プロダクト設計など開発以外の仕事にも挑戦できる
 Dataforceチームのエンジニアは本来の開発業務以外の動き方も希望に合わせてできます。 社内に利用ユーザーがいるため、ユーザーにヒアリングを行いながら新規施策の立案やUI/UX改善などに挑戦することが可能です。 立案した施策で業務効率や売上が向上した時は、大きなやりがいを得ることができます。

弊社の情報公開ポリシーのため、機能について深くお話はできませんでしたが、ブログに公開できないことでも、会社説明会やカジュアル面談などではお話ができることもありますので、Dataforceについてご興味がある方はぜひ質問してみてください!

レバレジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です!
ご興味のある方は、以下のリンクから是非ご応募ください。 https://recruit.jobcan.jp/leverages/recruit.jobcan.jp

Apollo Client導入により状態管理の複雑性を削減し開発効率を向上させた話

はじめに

こんにちは、20卒でレバレジーズ株式会社に入社した古賀です。 現在私は、若年層領域の事業を複数展開するヒューマンキャピタル事業部に所属しており、営業支援システムの開発に携わっています。現在は、リプレイスに取り組んでいます。私を中心にバックエンド、フロントエンドの技術選定を行い、GraphQL、React、Apollo Clientを採用しました。

この記事では、Apollo Clientを採用した理由、デメリットを補うために行った設計上の工夫をお伝えします。なお、GraphQL、Reactを採用した理由については詳細に説明はしない予定です。

前提

バックエンドにはGraphQLを採用することにしました。採用した目的は下記となります。

  • 営業支援システムや周辺サービスに存在する、似たようなロジックをGraphQLで抽象化し実装速度を向上するため
  • クライアントファーストなAPI設計にすることで、フロントエンドリソース不足を解消するため

Apollo Clientとは、GraphQL専用APIクライアントを兼ねた状態管理ライブラリです。下記のような特徴があります。

  • 取得データ、ロード、エラーの状態のロジックがApollo ClientのフックAPIにカプセル化されており、これらに関して宣言的に記述可能
  • フェッチしたデータが自動的に正規化されキャッシュされる

Apollo Clientでは、キャッシュをReduxでいうstoreとして利用し状態管理することが可能です。詳しくは公式をご覧ください。

状態管理で抱えていた問題

フロントエンドはjQueryとReact/Reduxが混在。jQueryからReact/Reduxに書き換えていたため、「サーバーからデータ取得→store格納までの処理の記述量が多い」問題が発生していました。

状態管理ライブラリの選択

上記問題を解決するために、状態管理ライブラリをApollo Clientに選択しました。 選択の流れについて説明します。候補は、既存のReduxに加え、バックエンドがGraphQLなのでApollo Client、Relayとなり、Relayは情報が少ないこともあり除外。次に、Apollo Clientのメリット・デメリットを調査しました。

  • メリット
    • キャッシュ機構をstoreとして利用することで、サーバーから取得したデータをstoreに格納する処理が不要になり、コード量が削減される
    • storeの設計が不要になり設計工数が削減される
  • デメリット
    • どこからでもAPIリクエストしキャッシュが更新されるので、バグ発生箇所の当たりをつけづらく開発効率が低下する

「サーバーからデータ取得→store格納までの処理の記述量が多い」問題は解決できそうですが、新たなデメリットが発生しそうな懸念があったため、Fragment Colocationで解決できると考え、導入しました。

デメリットのカバー

子コンポーネントで欲しいデータをFragmentで定義、親コンポーネントで欲しいデータを子コンポーネントのFragmentを組み合わせて定義、最上位階層の子コンポーネントで親コンポーネントのFragmentを組み合わせてQueryを定義し一括取得する手法になります。詳細はApollo Clientの公式を御覧ください。

トップのコンポーネントで取得したデータは、コンポーネントの再利用性を高めるためにprops経由で子コンポーネントに流し込むことにしました。

ディレクトリ構成は下記になります。

lib
 ├── atoms
 ├── molecules
 ├── organisms
 │    └─ parts/
 │    │   └─ {コンポーネント名}.tsx
 │    │   └─ {コンポーネント名}.stories.mdx
 │    └─ {モデル名}/
 │          └─ {コンポーネント名}/
 │              └─ Container.tsx:この中にQuery, Mutation, Fragmentも書く。
 │              └─ Presentation.tsx
 │              └─ Story.stories.mdx
 └─── pages/
      └─ {画面名}/
          └─ Container.tsx
          └─ Presentation.tsx

下記サンプルコードになります。Fragment Colocationについて示したいため、Query部分のみ記載してます。フックや型はgraphql-codegenで生成したものを使用しています。 organisms/Job/JobClient/Container.tsx のFragmentをまとめ上げて取得していることが分かります。

Fragment Colocationとは別ですが、サーバーから取得しないが、コンポーネント間で共有したいデータはuseReducerを利用。

pages/Sample/Container.tsx

import React, { useEffect, useReducer } from 'react'
import { Presentation } from './Presentation'
import { gql } from '@apollo/client'
import { ClientTest, JobTest } from '@/ts/objects/organisms/Job/JobClient/Container'
import { useIndexQuery } from 'generated/graphql'

const INDEX_QUERY = gql`
    query index {
        clients {
            ...ClientTest
        }
        jobs {
            ...JobTest
        }
    }
    ${ClientTest}
    ${JobTest}
`

export interface Store {
    isInit: boolean
    selectedClient?: string
    selectedJob?: string
}

type ActionType = 'SET_INIT' | 'SET_VALUE' | 'SET_CLIENT'

export interface Action {
    type: ActionType
    payload?: any
}

const reducer: React.Reducer<Store, Action> = (store, action) => {
    switch (action.type) {
        case 'SET_INIT':
            return {
                isInit: false,
                selectedClient: String(action.payload.clients[0].id),
                selectedJob: String(action.payload.jobs[0].id),
            }
        case 'SET_VALUE':
            return {
                ...store,
                [action.payload.name]: action.payload.values,
            }
        case 'SET_CLIENT':
            return {
                ...store,
                selectedClient: action.payload.values,
                selectedJob: action.payload.jobs !== 0 ? String(action.payload.jobs[0].id) : '1',
            }
        default:
            return store
    }
}

export const Container: React.FC = () => {
    const { data, loading, error } = useIndexQuery()
    const initialStore: Store = {
        isInit: true,
    }

    const [store, dispatch] = useReducer(reducer, initialStore)

    useEffect(() => {
        if (!data || !store.isInit) {
            return
        }

        dispatch({
            type: 'SET_INIT',
            payload: data,
        })
    }, [data])

    if (store.isInit && loading) {
        // 必要に応じてカスタマイズ
        return <div>Loading...</div>
    }

    if (error) {
        // 必要に応じてカスタマイズ
        return <div>Error...</div>
    }

    return <Presentation data={data} store={store} dispatch={dispatch} />
}

pages/Sample/Presentation.tsx

import React from 'react'
import { JobContainer } from '@/ts/objects/organisms/Job/JobClient/Container'
import { Store, Action } from './container'

interface Props {
    data: any
    store: Store
    dispatch: React.Dispatch<Action>
}

export const Presentation: React.FC<Props> = ({ data, store, dispatch }) => {
    return (
        <>
            <JobContainer
                clients={data.clients}
                jobs={data.jobs}
                selectedClient={store.selectedClient ?? '1'}
                selectedJob={store.selectedJob ?? '1'}
                dispatch={dispatch}
            />
        </>
    )
}

organisms/Job/JobClient/Container.tsx

import React from 'react'
import { ClientTestFragment, JobTestFragment } from 'generated/graphql'
import { gql } from '@apollo/client'
import { JobPresentation } from '@/ts/objects/organisms/Job/JobClient/Presentation'

export const ClientTest = gql`
    fragment ClientTest on Client {
        id
        name
    }
`

export const JobTest = gql`
    fragment JobTest on Job {
        id
        name
        clientId
    }
`

interface Props {
    clients: Array<ClientTestFragment>
    jobs: Array<JobTestFragment>
    selectedClient: string
    selectedJob: string
    dispatch: React.Dispatch<any>
}

export const JobContainer: React.FC<Props> = ({ clients, jobs, selectedClient, selectedJob, dispatch }) => {
    const handleChangeClient: React.ChangeEventHandler<HTMLSelectElement> = e => {
        const nextClientId = e.target.value ? String(e.target.value) : String(clients[0].id)
        dispatch({
            type: 'SET_CLIENT',
            payload: {
                name: 'selectedClient',
                values: nextClientId,
                jobs: jobs.filter((job: JobTestFragment) => String(job.clientId) === nextClientId),
            },
        })
    }

    const handleChangeJob: React.ChangeEventHandler<HTMLSelectElement> = e => {
        dispatch({
            type: 'SET_VALUE',
            payload: {
                name: 'selectedJob',
                values: e.target.value ? String(e.target.value) : String(jobs[0].id),
            },
        })
    }

    return (
        <JobPresentation
            clients={clients}
            jobs={jobs}
            selectedClient={selectedClient}
            selectedJob={selectedJob}
            handleChangeClient={handleChangeClient}
            handleChangeJob={handleChangeJob}
        />
    )
}

organisms/Job/JobClient/Presentation.tsx

import React from 'react'
import { ClientTestFragment, JobTestFragment } from 'generated/graphql'

interface Props {
    clients: Array<ClientTestFragment>
    jobs: Array<JobTestFragment>
    selectedClient: string
    selectedJob: string
    handleChangeClient: React.ChangeEventHandler<HTMLSelectElement>
    handleChangeJob: React.ChangeEventHandler<HTMLSelectElement>
}

export const JobPresentation: React.FC<Props> = ({ clients, jobs, selectedClient, selectedJob, handleChangeClient, handleChangeJob }) => {
    return (
        <>
            <div>
                <label>
                    企業:
                    <select value={selectedClient} onChange={handleChangeClient}>
                        {clients.map((client: ClientTestFragment) => {
                            return (
                                <option value={client.id} key={client.id}>
                                    {client.name}
                                </option>
                            )
                        })}
                    </select>
                </label>
                <label>
                    求人:
                    <select value={selectedJob} onChange={handleChangeJob}>
                        {jobs
                            .filter((job: JobTestFragment) => String(job.clientId) === selectedClient)
                            .map((job: JobTestFragment) => {
                                return (
                                    <option value={job.id} key={job.id}>
                                        {job.name}
                                    </option>
                                )
                            })}
                    </select>
                </label>
            </div>
        </>
    )
}

導入して分かったこと

実装したメンバーの声も含め、よかったこと、困ったことを紹介します。

  • よかったこと
    • storeに格納する処理がなくなったので、想定通りコード量が減った
    • store設計が不要になったので、設計工数を削減できた
    • 「前提」の章で述べたとおり、非同期処理に関わるデータ、ロード、エラーの状態を宣言的に扱えて書きやすかった
    • graphql-codegenによる型生成のおかげで、型安全かつ簡単にFragment Colocationを実現できた(型安全性なフロントエンド開発についてはこちら
    • サーバーから取得しないが、コンポーネント間で共有したいデータはuseReducerで管理した。管理したいデータ数がそんなに多くないこと、objectやarray等の複雑になりうるデータ構造を管理することも少なかったことから、useReducerでも問題なかった
  • 困ったこと
    • react-selectの仕様で、コンポーネントに渡すために整形する処理を完全に消すことができなかった
    • クエリやFragmentは元々他ディレクトリに切り出していたが、それぞれpages/{モデル名}配下、organisms/{モデル名}配下のContainer.tsxに定義。 理由は下記の通りです。
      • クエリやFragment毎にディレクトリを切ると行ったり来たりが多く開発が大変になるため、特に慣れてない初期は大変だと判断した
      • PresentationにUI部分は切り出しており、ContainerにクエリやFragmentを記載してもファイルサイズが長大になりすぎないため
    • Apollo Clientとは関係ないが、データを受け取るコンポーネントの再利用を考えると、Atomic Designには適切な置き場所がないので、propsをバケツリレーで渡していくことになる

最後に

Reduxのstore構造の複雑さの軽減、コード量の削減、(Apollo Clientではないですが)GraphQL周辺ツールの充実さによって、開発効率を向上させることができました。 私は新卒2年目ですが、この技術選定を担当させていただきました。レバレジーズでは、経験が浅くても提案が受け入れてもらえる環境があります。このような環境に興味がある方はぜひ一緒に働きましょう。

新卒1年目が開発プロセス改善のためにした3つのこと

はじめに

みなさん、こんにちは! メディカルケア事業部の『看護のお仕事』にてシステム担当をしている渡辺と申します。私は2020年に新卒で入社し、同年7月からメディカルケア事業部のエンジニアとして配属されました。職種や経験年数に関わらず自発的な提案を受け入れて任せるレバレジーズの企業文化の下、私も配属後から開発プロセスの改善案を出すうちに、去年の終わり頃から媒体の責任者となり、『看護のお仕事』のサイト品質改善のためにさまざまな施策の実装を行っています。

今回は、『看護のお仕事』で開発プロセス改善を行ったことについてお話したいと思います。媒体責任者として、開発に関わるメンバーがスムーズに作業にあたることができるよう開発プロセスの改善を進めてきました。

この記事が開発プロセスの改善策を模索している方やレバレジーズに興味のある方の一助となれば幸いです。

『看護のお仕事』について

『看護のお仕事』は看護師向けの転職支援サービスです。『看護のお仕事』は無料で使うことができ、自分にあった職場を納得の行くまで探すことをサポートします。本記事をご覧いただいている方で、看護師のお知り合いがいましたら、ぜひ『看護のお仕事』をご紹介ください!

各種ミーティングについて

『看護のお仕事』の開発チームは、スクラム開発を参考にオリジナルのスクラム開発体制でサイト運営を行っています。また、スプリントは2週間単位で回しています。今回お話しする中で、関係のあるミーティングは下記になります。

  • 施策レビュー会
    • 参加者:オウンド責任者、ディレクター、デザイナー兼コーダー、エンジニア
    • 内容:施策について意見を出し、話し合う会です。『看護のお仕事』のサイト品質改善を進めるにあたって施策が有効かなどを話し合うための場になります。
  • 工数見積もりのミーティング
    • 参加者:エンジニア
    • 内容:実装に必要な工数を見積もります。この時に実装方針についても話し合います。見積もりにはプランニングポーカーを採用しています。
  • 振り返り会
    • 参加者:オウンド責任者、ディレクター、デザイナー兼コーダー、エンジニア
    • 内容:KPTを用いてスプリントの振り返りを行います。

実際に何したの?

では、実際に行った改善策について3つお話しします。

①やるべきタスクの精査

『看護のお仕事』開発チームでは、実装が必要なタスクに工数付けを行います。エンジニア個々人のリソースと工数管理の観点から、見積もりは絶対見積もりで行っています。

私がチームへ配属されてから少し経過した頃、ABテストを行う施策に実際の作業時間が5分であるにも関わらず、1時間の見積もりを割り当てていることに気づきました。ABテストの施策は実装方法の改善がされた結果、5分で作業が出来るほどマニュアル化されており、エンジニア以外でも作業が可能なものだとわかりました。そのため、そもそもエンジニアが対応する必要があるかを見直しました。

そこで、作業を同施策の他の対応と合わせてコーダーに巻き取ってもらうことを相談し、そのようになりました。結果として、エンジニアで対応が必要かどうかの見直しができ、1時間の工数がつく施策がスプリント内で3~5施策ほどあることが続いていましたので、最大5時間の工数見積もりの削減になりました。また、施策ごとにエンジニアで対応すべきかを確認することの重要性を再確認でき、やるべきタスクの精査をよりしっかりと行うようになりました。

②工数付けプロセス改善

工数をつけるために仕様を把握する必要があります。しかし、『看護のお仕事』開発チームでは工数をつけるときもまだ仕様が詰まりきっていないタスクが多々発生しており、そのことを関係者間で問題であるという共通認識を持てていませんでした。同時に、特定のメンバーが工数付けのミーティングまでにディレクターと短い時間で仕様についてヒアリングを行っていたため、該当メンバーに負担がありました。

この状況を改善するにあたって、ディレクターへ以下のお願いをしました。 ディレクターもしくは依頼者が、工数付けのミーティングまでに施策の実装をする上で懸念事項がないか事前に確認すること 施策レビュー会中に話がまとまらない場合には別途ミーティングの場を設けること また、エンジニアからも考慮して欲しい観点について施策レビュー会中に発言をするようにしています。

振り返り会にて相談をしたことで、仕様に抜け漏れが発生していることを共通認識として持つことができました。その結果、既存のミーティングをうまく活用することができ、仕様確認に関するやりとりなどの手間やメンバーへの負担を軽減することができました。

③工数ズレをなくすために

ソースコードレビューでは、レビューを受けてから大幅に実装方針を変更し手戻りすることや、レビュアーと実装者で追加の実装すり合わせが必要になることが多々ありました。これでは予定工数を超過してしまいますし、施策のリリースにも影響します。

その状況の根本的な原因は、レビュアーと実装者との間で実装方針のすり合わせが不十分なことでした。そこで工数付けの時間に実装方針を話し合い、その内容を実装タスクを管理しているものにコメントとして残すようにしました。このようにして、話し合った内容をいつでも確認できるようにすることでエンジニアチーム全体で共通認識をとりました。

後々の工数を減らすと同時に、実際の実装対応に近い工数を見積もることができ、工数付けの精度向上にもなりました。

終わりに

これらの開発プロセス改善は私が入社後、配属半年で行ったことになります。自身で考えた改善案とその対応策を上長に提案し、チームメンバーと共に相談しながら進めてきました。このようにできたのも、社会人歴や入社年数に囚われずに話を聞いてくれてそれを受け入れてもらえる環境のおかげです。

組織は変化していくものであると同時に、開発チームという組織も流動的なものです。エンジニアチームも続々とメンバーが加わり、『看護のお仕事』がサービスとして何をすべきかのフェーズも日々進化していきます。柔軟にやり方を模索しながらエンジニア以外の方々とも協力しながら日々サイト運営を行っております。

本記事が開発プロセスの改善を進めたい方や、弊社に興味のある方に意義あるものであると大変嬉しく思います。最後までご覧いただき、ありがとうございました!

tailwindcssを導入して感じたメリット・デメリットについて

はじめに

こんにちは、レバレジーズ株式会社の牛嶋です。 レバレジーズ株式会社で、teratailをはじめとしたサービスの開発に携わっております。現在、teratail管理画面のリニューアルに取り組んでおり、技術選定にあたって、CSSフレームワークとして、tailwindcssを採用しました。

この記事では、tailwindcssを採用した理由や、実際にフロントエンド開発で使ってみて良かった点や困った点を紹介します。弊チームの他のプロジェクトでは、CSS Modulesを採用しているため、それと比較しながら紹介していけたらと思います。

tailwindcssとは

tailwindcssとは、CSSフレームワークの一つです。最大の特徴として、ユーティリティファーストなアプローチでデザインを組み立てていく点が挙げられます。

CSS Modulesやstyled-componentでは、デザインをゼロベースで構築することができますが、CSSを一から書かなくてはならず、また、それの運用コストも発生します。 また、BootstrapやMUIなどのCSSフレームワークでは、コンポーネントが事前に用意されているので、効率的にデザイン実装ができる反面、デザインに画一性が生まれてしまいます。

その点、tailwindcssでは事前に提供されている低レベルのユーティリティクラスを用いて、実装速度を担保したまま、独自のデザインを組み立てることができます。

ユーティリティクラスとは

tailwindcssでは、m-6, text-whiteなどのクラスをユーティリティクラスと呼んでいます。これら一つ一つのユーティリティクラスには、対応するCSSプロパティが用意されています。

tailwindcssは、MUIなどが提供している事前に用意されたコンポーネントは提供しておらず、プリミティブなCSSプロパティに対応したユーティリティクラスを提供しています。これらをHTMLのclass属性に追加していくことにより、デザインを構築していきます。

実装例

弊チームでは、tailwindcssとCSS Modulesを採用しているので、それらの実装例を見ていきます。

tailwindcssでは、CSSを記述することなく、ユーティリティクラスを用いて、インラインスタイルでデザイン構築を行なっていきます。 一方、CSS Modulesでは、JSXファイルとSCSSファイルをわけて、デザインを構築していきます。

tailwindcssの場合

// 公式サイト(https://tailwindcss.jp/docs/utility-first)から引用
const ChitChat = () => {
 return (
   <div className="max-w-sm mx-auto flex p-6 bg-white rounded-lg shadow-xl">
   <div className="flex-shrink-0">
     <img className="h-12 w-12" src="/img/logo.svg" alt="ChitChat Logo">
   </div>
   <div className="ml-6 pt-1">
     <h4 className="text-xl text-gray-900 leading-tight">ChitChat</h4>
     <p className="text-base text-gray-600 leading-normal">You have a new message!</p>
   </div>
   </div>
 )
}
 
export default ChitChat;

CSS Modulesの場合

// 公式サイト(https://tailwindcss.jp/docs/utility-first)から引用
import styles from “./chitChat.modules.scss”
 
const ChitChat = () => {
 return (
   <div className={styles.chatNotification}>
     <div className={styles.chatNotificationLogoWrapper}>
       <img className={styles.chatNotificationLogo} src="/img/logo.svg" alt="ChitChat Logo">
     </div>
     <div className={styles.chatNotificationContent}>
       <h4 className={styles.chatNotificationTitle}>ChitChat</h4>
       <p className={styles.chatNotificationMessage}>You have a new message!</p>
     </div>
   </div>
 )
}
 
export default ChitChat;
.chatNotification {
 display: flex;
 max-width: 24rem;
 margin: 0 auto;
 padding: 1.5rem;
 border-radius: 0.5rem;
 background-color: #fff;
 box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.chatNotificationLogo-wrapper {
 flex-shrink: 0;
}
.chatNotificationLogo {
 height: 3rem;
 width: 3rem;
}
.chatNotificationContent {
 margin-left: 1.5rem;
 padding-top: 0.25rem;
}
.chatNotificationTitle {
 color: #1a202c;
 font-size: 1.25rem;
 line-height: 1.25;
}
.chatNotificationMessage {
 color: #718096;
 font-size: 1rem;
 line-height: 1.5;
}

なぜtailwindcssを導入したか

tailwindcssは、「チームとして新しい技術にチャレンジしたかった」という理由に加え、下記の効果を期待して導入しました。

  • 実装速度の向上
  • CSS設計と運用コストの削減
  • スコープが担保した上でのデザイン
  • CSSファイルサイズの最適化

詳細は以下の章で説明させていただきます。

メリット

実装速度が向上する

tailwindcssはユーティリティクラスの他にも、hoverなどの容易に適用することができる疑似クラスバリアントや、レスポンシブデザインを容易に実現できる、レスポンシブユーティリティバリアントを提供しています。

以下は一例です。mdをプレフィックスとしてつけることで、指定されたスタイルに「min-width: 768px」のメディアクエリが適用されます。

// 公式サイト(https://tailwindcss.jp/docs/responsive-design)から引用
<div className="md:flex">
 <div className="md:flex-shrink-0">
   <img className="rounded-lg md:w-56" src="https://images.unsplash.com/photo-1556740738-b6a63e27c4df?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=448&q=80" width="448" height="299" alt="Woman paying for a purchase">
 </div>
 <div className="mt-4 md:mt-0 md:ml-6">
   <div className="uppercase tracking-wide text-sm text-indigo-600 font-bold">Marketing</div>
   <a href="#" className="block mt-1 text-lg leading-tight font-semibold text-gray-900 hover:underline">Finding customers for your new business</a>
   <p className="mt-2 text-gray-600">Getting a new business off the ground is a lot of hard work. Here are five ideas you can use to find your first customers.</p>
 </div>
</div>

最初こそ慣れるまで時間がかかると思いますが、ある程度の書き方を覚えてしまえば、CSSを直接で書くよりも速く実装することが可能だと思います。

CSS設計と運用コストの軽減

tailwindcssを用いると、ユーティリティクラスを直接class属性に記述するだけなので、CSSのクラス設計をする必要がなくなります。CSS設計の規則を考える必要もないですし、それらを基準にして、class名を考える必要もなくなります。

また、CSSファイルを作成する必要がなくなるため、ファイルの運用コストもなくなります。そもそもファイルを作成しないので、CSSが肥大化することもありません。

スコープが担保されるので、安全にデザインを変更できる

tailwindcssは基本的にclass属性に記述していくので、HTMLファイルに変更が閉じられ、自然とデザインを適用するスコープが担保されます。

そのため、任意の箇所のデザインを変更することになっても、影響範囲を考えることなく、安全にデザインを変更することができます。

CSSファイルのサイズを小さく出来る

tailwindcssはPurgeCSSを採用しており、本番用にビルドする場合には、未使用のスタイルを自動的に削除します。

その結果、プロジェクトで使用しているスタイルのみを出力したCSSファイルが生成され、ビルドサイズが小さくなります。

purgecss.com

デメリットと対応方法

慣れるまではクラスを探すのが面倒?

tailwindcssのユーティリティクラスに慣れるまでには、CSSプロパティに対応するクラスを探すのに苦労する可能性があります。最初のうちは多少時間を取られると思います。

しかし、エディタのプラグインを使えばある程度負担を軽減することができます。 例えば、VS Codeで提供されているプラグインの「Tailwind CSS InteliSense」を利用すれば、クラス名の自動補完等の機能を使うことができ、比較的開発が楽になるかと思います。

marketplace.visualstudio.com

メンテナンス性が落ちる?

ユーティリティクラスをクラス属性に書いていくと、多くのクラスがクラス属性内に記述されることになります。結果的に、そのクラスに記述された内容が何をしているかわからない状態になることもあると思います。

その点、tailwindcssはapplyというディレクティブを提供しており、既存のユーティリティクラスを独自のカスタムCSSにインライン化することができます。

tailwindcss.com

また、別の方法として、必要な単位でコンポーネントを抽出する方法があります。

tailwindcss.com

個人的には、後者の方法の方がメリットを享受できると考えています。具体的な理由として、Reactをはじめとするコンポーネント志向のフレームワークとの相性が良い点、また無用なユーティリティクラスを増やすことがない点などがあげられます。

提供されていないCSSが必要な場合はどうする?

tailwindcssのユーティリティクラスで提供されていないデザインを実装したい場合があるかもしれません。基本的にデフォルトで実装するには十分な量のユーティリティクラスが提供されています。

しかし、万が一足りなくなった場合でも容易に拡張することができます。公式サイトで独自のユーティリティクラスを追加するためのベストプラクティスが紹介されています。

tailwindcss.com

今後の展望

上記メリットでは紹介しなかったのですが、tailwindcssには設定ファイル(tailwind.config.js)が存在します。設定ファイル上で、自分たちのデザインシステムに沿って、tailwindcssをカスタマイズすることができます。以下が設定ファイルの例です。

// Example `tailwind.config.js` file
// 公式より引用(https://tailwindcss.jp/docs/configuration)
module.exports = {
 important: true,
 theme: {
   fontFamily: {
     display: ['Gilroy', 'sans-serif'],
     body: ['Graphik', 'sans-serif'],
   },
   extend: {
     colors: {
       cyan: '#9cdbff',
     },
     margin: {
       96: '24rem',
       128: '32rem',
     },
   },
 },
 variants: {
   opacity: ['responsive', 'hover'],
 },
}

上記のように、設定ファイルでデザインをカスタマイズすることが可能なので、tailwindcssはデザインシステムと非常に相性がいいと考えています。

弊チームでは、今後サービスの横展開も考えており、その際にデザインシステムの作成とともに、tailwindcssを利用したUIライブラリを実装していけたらと考えています。

最後に

CSSフレームワークにtailwindcssを採用したことで、設計面での負担軽減や、実装速度向上等、開発面で多くのメリットを享受することができました。

今後の展望で記載した通り、個人的にtailwindcssはデザインシステムを組み合わせてこそ真価を発揮します。サービスが展開していくことも考え、tailwindcssをベースとしたUIライブラリを構築していけたらと考えています。今後の取り組みなどについては、また別の機会で知見を共有できたらと思います。

私は現在、新卒2年目なのですが、今回の技術選定を担当させてもらいました。弊社には、意思決定に合理性と納得性があれば年次に関係なく、自由に発言や提案ができる環境があります。 「主体性を持って働きたい人」や「新しい技術にチャレンジしたい人」には最適な環境だと思うので、興味のある方はぜひ一緒に働きましょう。

参考サイト

tailwindcss.com

frourioを使って1ヶ月で管理画面をリリースした話

はじめに

こんにちは、レバテック開発部の河村です。 私はレバテック各種メディアのリプレイスを担当しており、バックエンドを中心にフルスタック開発を行っています。

今回は管理画面のリリースで採用した、フルスタックフレームワークであるfrourioについて、frourioを採用した理由や使ってみて良かったこと、困ったことを紹介します。 この記事を通して、frourioのメリット、デメリットだけでなく、レバテック開発部ではどのような背景のもと、技術・アーキテクチャの選定を行っているのか、どれくらいのスピード感で開発を行っているのかをお伝えできればと思います。

なお、この記事ではfrourioにおける環境構築や使い方等の説明は割愛させていただきます。


開発背景・経緯

今回、開発する対象となった管理画面は、レバテックの各メディアで運用する記事やセミナー情報、エントリー情報を管理するものになります。

すでに管理画面として、PHP(CakePHP)をベースとした技術で作られているものを利用している状態でしたが、 PHP自体のバージョンも古く、利用しているライブラリなどを含めレガシーな状態になっており、開発速度、品質、運用、保守性が低下している状態でした。 また、レバテック組織全体でTypeScriptの標準化を進めており、管理画面のリニューアルが必須の状態でした。

管理画面のため、特別凝ったデザイン等は必要ありませんが、利用するロールやメンバーが多く、複雑な機能が必要となることもあり、管理画面のフレームワーク(ReactAdminやAdminBroなど)を利用してしまうと、機能の実装が困難になる可能性もあり、基本的には拡張性が高い状態、フルスクラッチで開発できる環境を構築する必要がありました。

さらに、事業側の方々の要望もあり、約1か月程度での本番リリース、稼働開始が必要だったため、 上記の背景、経緯を踏まえた上で開発速度が出せるようなフレームワークを選定する必要がありました。

このような背景、経緯があり、frourioを採用することにしました。 frourioを採用した理由については後ほど詳細を説明させていただきます。


frourioとは

frourioは一言で言うと、TypeScript製のフルスタックWebフレームワークで、詳細については、公式ドキュメントまたはGithubを参照してください。


frourioの特徴

frourioの1番の特徴は、フロントエンドからバックエンドまですべてTypeScriptでかつ型安全に開発できるところです。 以下の図が、frourioが内包しているフレームワークやORMになります。

上記の図の通り、frourioはTypeScriptをベースに利用できるフレームワークを内包しています。 フロントエンドだとNext.jsやNuxt.jsを選択できたり、ORMだとTypeORMやPrismaを利用することができます。 また、Node.js系のフレームワークでハイパフォーマンスと言われているfastifyも利用することができ、安定したパフォーマンスを出すことも期待できます。

その他にも以下のような特徴がありますが、本記事では割愛させていただきますので、気になる方はぜひ調べてみてください。

  • コマンド1発でフロントエンドからバックエンドの環境構築
  • Vercel社が開発しているReact Hooksライブラリ「SWR」に対応
  • 関数ベースでControllerを実装可能
  • 関数ベースでDI(Dependency Injection)の実現

frourioを採用した理由

frourioを採用した理由として、大きく以下の3つになります。

  • TypeScriptを利用して型安全に開発できる
  • チームとして利用したいフレームワークが内包されている
  • 自動生成により開発速度を大きく向上させることができる

上記の理由について、以下、詳細に説明させていただきます。

TypeScriptを利用して型安全に開発できる

frourioの特徴でもお伝えしましたが、frourioはフロントエンドからバックエンドまですべてTypeScriptでかつ型安全に開発することができます。型安全に開発することで、不正な動作を事前に防げたり、フロントエンドとバックエンドをつなぐ部分のInterfaceを明示的に宣言でき、フロントエンドからバックエンドのAPIを呼び出す際も安全にかつスムーズに呼び出すことができます。

例えば、以下のように、バックエンドのAPIのInterfaceを定義し、

export type Task = {
  id: number
  label: string
  done: boolean
}

export type Methods = {
  post: {
    reqBody: Pick<Task, 'label'>
    resBody: Task
  }
}

フロントエンドからInterfaceを参照しながら、安全にかつスムーズにAPIを呼び出すことができます。

await apiClient.tasks.post({ body: { label } })

ここで、バックエンドのAPIのInterfaceを定義しましたが、 frourioのルールにしたがって定義することで、ApiClient(APIのクライアント)部分を自動生成してくれます。 こちらの自動生成については、自動生成により開発速度を大きく向上させることができるの箇所で説明したいと思います。

チームとして利用したいフレームワークが内包されている

チームとして、以下のフレームワークを利用して開発工数をできる限り抑えることを目指していました。

  • フロントエンド
    • Nuxt.js
    • Next.js
  • バックエンド
    • TypeORM

まず、フロントエンドですが、frourioではNuxt.js、Next.jsともに利用することができます。 チーム内ではVue.js、Nuxt.jsの方が経験のあるメンバーが多いため、どちらを選定するかで迷いましたが、TypeScriptとの相性、チームメンバーのスキル向上、モチベーションアップを考慮した上で、Next.jsを選定しました。型安全に開発できることをメインに置いていることもあり、TypeScriptとの相性の良いReact.jsをベースとしたNext.jsを選定することが当然であるかもしれませんが、それ以上に、チーム内でReact.js、Next.jsの技術を身につけていきたいというモチベーションが選定において大きな判断材料になったかと思います。

ちらっと、TypeScriptとの相性について述べましたが、こちらの記事が参考になりますので、気になる方は参考にしてみてください。

また、バックエンド、主にORMになりますが、TypeORMを採用しました。 現在、レバテック全体で各種機能のマイクロサービス化が進んでおり、TypeORMを利用しているチームやメンバーが多いことから選定しました。個人的にはPrismaを使ったみたいという気持ちもありましたが、開発工数をできる限り抑えるということで、すでに知見が溜まっているTypeORMを選定しました。 これにより、バックエンドの開発は、今までとほぼ変わらず、frourioのお作法を覚えるだけで開発できる環境を整えることができました。

余談:CSSフレームワーク

フルスクラッチで開発する以上、UIを作成するために、管理画面のフレームワーク(ReactAdminやAdminBro等)と比べて、UIの実装にある程度の工数がかかってしまいます。なので、そこで、CSSフレームワークでReactと相性の良いMaterial-UIを採用しています。 さらに、管理画面においては、同じような画面、Componentの使い回しが多くなることから、Atomic Designの概念を採用して、再利用される可能性のあるComponentはできる限り共通化(atoms, moleculed, organismsを適用)し、UIの実装にかかる工数をできる限り削減しました。

自動生成により開発速度を大きく向上させることができる

frourioのお作法にしたがって開発することで、様々な部分を自動生成をしてくれて、開発工数を大きく削減でき、開発速度を向上させることができます。個人的に、特にフロントエンドとバックエンドをつなぐ部分、ApiClient(APIのクライアント)部分の自動生成がとても便利だったので、紹介させていただきます。

  • ディレクトリを作成するだけで、Controllerを自動作成してくれる
  • APIのInterfaceを定義するだけで、ApiClientを自動生成してくれる

ディレクトリを作成するだけで、Controllerを自動作成してくれる

frourioの機能を利用することで、バックエンドのController部分に該当する./server/apiディレクトリに対象のエンドポイント用のディレクトリを作成するだけで、必要なファイルを生成してくれます。

上記の機能を説明する前に、まず、frourioを使って環境構築した後のディレクトリ構成を紹介します。

frourioのディレクトリ構成

frourioのコマンドを使って環境構築を行った際の設定が以下になります。 Next.jsのcreate-next-appのようなコマンドがfrourioにもあるので環境構築は簡単にできます。

frourio環境構築時の設定1
frourio環境構築時の設定2

上記を行った結果、以下のようなディレクトリ構成が出来上がります。

.
├── README.md
├── aspida.config.js
├── components # React用のComponent
├── ecosystem.config.js
├── jest.config.ts
├── next-env.d.ts
├── package.json
├── pages # Next.jsのpagesに該当
├── public # フロントエンド用の静的ファイル
├── server # バックエンド
│   ├── $orm.ts
│   ├── $server.ts
│   ├── api # バックエンドのController部分に該当
│   ├── entity # TypeORMのEntity
│   ├── entrypoints # バックエンドのサーバーを立ち上げるための設定等
│   ├── index.js
│   ├── migration # TypeORMによって生成されたMigrationファイル
│   ├── ormconfig.ts
│   ├── package.json
│   ├── service # レイヤードアーキテクチャ的に言うと、アプリケーションサービスに該当
│   ├── static # バックエンド用の静的ファイル
│   ├── subscriber # TypeORMのSubscriber
│   ├── test # バックエンド用のテスト
│   ├── tsconfig.json
│   ├── types # バックエンド用の型定義ファイル
│   ├── validators # Service等で利用したいValidation用のDTOに該当
│   ├── webpack.config.js
│   └── yarn.lock
├── styles # CSS
├── test # フロントエンド用のテスト
├── tsconfig.json
├── utils # フロントエンド用のUtil
│   └── apiClient.ts
└── yarn.lock

正直、利用しないディレクトリ等も生成されてしまいますが、不要なものは後から取り除く形で問題ないかと思います。 (実際に、./styles./server/service./server/subscriberは不要だったので取り除いています)

また、1つのディレクトリ内でフロントエンド、バックエンドを管理しているために、一見モノリシックに見えますが、Interface(型定義)で繋がっていること以外は、フロントエンド、バックエンド、それぞれに別のpackage.jsonがあり、別のプロジェクトとして扱うことができます。ライブラリ等が依存し合ったりすることもなく、仮にバックエンドを別のものに差し替えたとしても、フロントエンドのAPIの呼び出し部分を変更するだけで可能になります。

./server/apiディレクトリにディレクトリを作成してみると

上記のディレクトリ構成の通り、./server/apiディレクトリがバックエンドのController部分に該当します。 補足ですが、frourioのコマンドで環境構築をすると、デフォルトでサンプルのコードが作成されます。 また、$がつくファイルはfrourio側で生成されるファイルになります。

frourioの自動生成機能1

こちらのディレクトリに、hogehogeディレクトリを作成して、バックエンド側をビルドすると以下のようになります。 ホットリロードでも生成されるので、実際にはサーバーを起動したまま作成していくことになるかと思います。

frourioの自動生成機能2

上記のようにファイルが生成されており、実際のファイルの中身は以下のようになります。 メソッド自体はサンプル的なものになっていますが、これだけでも自動生成してくれるだけで恩恵を得られるかと思います。

index.ts

export type Methods = {
  get: {
    resBody: string
  }
}

controller.ts

import { defineController } from './$relay'

export default defineController(() => ({
  get: () => ({ status: 200, body: 'Hello' })
}))

$relay.ts

/* eslint-disable */
// prettier-ignore
import { Injectable, depend } from 'velona'
// prettier-ignore
import type { FastifyInstance, onRequestHookHandler, preParsingHookHandler, preValidationHookHandler, preHandlerHookHandler } from 'fastify'
// prettier-ignore
import type { Schema } from 'fast-json-stringify'
// prettier-ignore
import type { HttpStatusOk } from 'aspida'
// prettier-ignore
import type { ServerMethods } from '../../$server'
// prettier-ignore
import type { Methods } from './'

// prettier-ignore
type Hooks = {
  onRequest?: onRequestHookHandler | onRequestHookHandler[]
  preParsing?: preParsingHookHandler | preParsingHookHandler[]
  preValidation?: preValidationHookHandler | preValidationHookHandler[]
  preHandler?: preHandlerHookHandler | preHandlerHookHandler[]
}
// prettier-ignore
type ControllerMethods = ServerMethods<Methods>

// prettier-ignore
export function defineResponseSchema<T extends { [U in keyof ControllerMethods]?: { [V in HttpStatusOk]?: Schema }}>(methods: () => T) {
  return methods
}

// prettier-ignore
export function defineHooks<T extends Hooks>(hooks: (fastify: FastifyInstance) => T): (fastify: FastifyInstance) => T
// prettier-ignore
export function defineHooks<T extends Record<string, any>, U extends Hooks>(deps: T, cb: (d: T, fastify: FastifyInstance) => U): Injectable<T, [FastifyInstance], U>
// prettier-ignore
export function defineHooks<T extends Record<string, any>>(hooks: (fastify: FastifyInstance) => Hooks | T, cb?: (deps: T, fastify: FastifyInstance) => Hooks) {
  return cb && typeof hooks !== 'function' ? depend(hooks, cb) : hooks
}

// prettier-ignore
export function defineController(methods: (fastify: FastifyInstance) => ControllerMethods): (fastify: FastifyInstance) => ControllerMethods
// prettier-ignore
export function defineController<T extends Record<string, any>>(deps: T, cb: (d: T, fastify: FastifyInstance) => ControllerMethods): Injectable<T, [FastifyInstance], ControllerMethods>
// prettier-ignore
export function defineController<T extends Record<string, any>>(methods: (fastify: FastifyInstance) => ControllerMethods | T, cb?: (deps: T, fastify: FastifyInstance) => ControllerMethods) {
  return cb && typeof methods !== 'function' ? depend(methods, cb) : methods
}

追加したControllerの場合でも、開発側は特別な設定を追加することなく、frourio側が自動に設定してくれます。 実際には、./server/$server.tsに自動で設定されますが、ファイルがけっこうな行数になってしまうので、ここでは割愛させていただきます。

APIのInterfaceを定義するだけで、ApiClientを自動生成してくれる

frourioでは、標準でTypeScript製のREST APIクライアントであるaspidaを利用して、バックエンドのAPIにアクセスすることができます。そのため、フロントエンド側に./utils/apiClient.tsが生成され、aspidaの設定が追加されています。

├── utils # フロントエンド用のUtil
│   └── apiClient.ts

apiClient.ts

import aspida from '@aspida/axios'
import api from '~/server/api/$api'

export const apiClient = api(aspida())

上記の通り、~/server/api/$api.tsを読み込んでいるのですが、 こちらがバックエンド側で定義したAPIのInterfaceを元に、自動生成されたClientの接続周りをラップしたものになります。 例えば、上記の例(hogehoge)の場合だと、~/server/api/$api.tsは以下のように生成されます。

/* eslint-disable */
// prettier-ignore
import { AspidaClient, BasicHeaders, dataToURLString } from 'aspida'
// prettier-ignore
import { Methods as Methods0 } from '.'
// prettier-ignore
import { Methods as Methods1 } from './hogehoge'

// prettier-ignore
const api = <T>({ baseURL, fetch }: AspidaClient<T>) => {
  const prefix = (baseURL === undefined ? 'http://localhost:39809/api' : baseURL).replace(/\/$/, '')
  const PATH0 = '/hogehoge'
  const GET = 'GET'
  const POST = 'POST'
  const DELETE = 'DELETE'
  const PATCH = 'PATCH'

  return {
    hogehoge: {
      get: (option?: { config?: T }) =>
        fetch<Methods1['get']['resBody']>(prefix, PATH0, GET, option).text(),
      $get: (option?: { config?: T }) =>
        fetch<Methods1['get']['resBody']>(prefix, PATH0, GET, option).text().then(r => r.body),
      $path: () => `${prefix}${PATH0}`
    }
}

// prettier-ignore
export type ApiInstance = ReturnType<typeof api>
// prettier-ignore
export default api

これらのfrourioの機能を利用することで、フロントエンドは特段、APIを呼び出すためにAPI用のClientを実装する必要がなくなります。バックエンドのAPIを実装した時点で、フロントエンドはAPIを呼び出して開発を進めることができます。


frourioを使って困ったこと

現段階でfrourioを使って困ったことをいくつか紹介させていただきます。

  • ディレクトリ構成が変更しづらい
  • 不要な設定が思ったより追加されてしまう

ディレクトリ構成が変更しづらい

frourioのお作法にしたがって開発することで、様々な部分を自動生成をしてくれる分、ある程度、frourioが用意したディレクトリ構成に従わないと動作しない機能がいくつかあります。ただし、ORMに関する設定はよしなに変更できたりします(例えば、TypeORMであれば、ormconfig.tsの設定を変更したりなど)

バックエンドを簡単なレイヤードアーキテクチャの構成(UI層→Application層→Domain層→Presentation層)にしようと思っても、上記で紹介した./server/apiディレクトリを変更するとControllerを追加できなかったり、./server/validatorsディレクトリをApplication層のDTOとして利用しようと思っても、./server/validatorsディレクトリ内に作成しないとclass-validatorが機能しなかったりします。

これらの原因は明確で、frourioが生成される$が付いたファイルをよしなに書き換えたりすることができないためです。 例えば、validatorsであればserver/$server.tsに自動で設定が作成され、以下のように設定されています。

$server.ts(一部抜粋)

...
// prettier-ignore
import * as Validators from './validators'
...

// prettier-ignore
const createValidateHandler = (validators: (req: FastifyRequest) => (Promise<void> | null)[]): preValidationHookHandler =>
  (req, reply) => Promise.all(validators(req)).catch(err => reply.code(400).send(err))

// prettier-ignore
const formatMultipartData = (arrayTypeKeys: [string, boolean][]): preValidationHookHandler => (req, _, done) => {
  const body: any = req.body

  for (const [key] of arrayTypeKeys) {
    if (body[key] === undefined) body[key] = []
    else if (!Array.isArray(body[key])) {
      body[key] = [body[key]]
    }
  }

  Object.entries(body).forEach(([key, val]) => {
    if (Array.isArray(val)) {
      body[key] = (val as Multipart[]).map(v => v.file ? v : (v as any).value)
    } else {
      body[key] = (val as Multipart).file ? val : (val as any).value
    }
  })

  for (const [key, isOptional] of arrayTypeKeys) {
    if (!body[key].length && isOptional) delete body[key]
  }

  done()
}

上記の通り、frourioの機能を存分に活用するには、frourioのルールにある程度従う必要があります。 ただ、frourioの機能、特に自動生成周りの機能は、開発スピード、コストにおいてとても効果を発揮しますが、バックエンドをきれいなアーキテクチャで作成したいようなニーズとはマッチしないかと思います。特に、今回はある程度簡単な機能が多いということもありfrourioを採用しているので、無理してアーキテクチャを整える必要はなく、特に大きな問題とはなっていません。

必要のない設定が思ったより追加されてしまう

frourioの公式に従って環境構築を進めると、たしかにコマンド1発でフロントエンドからバックエンドの環境構築ができますが、その分、様々な設定が追加されている状態になります。不必要な設定があると開発者内で認識齟齬につながる可能性があるので、開発において不必要な設定は取り除いておいたほうがいいかと思います。

例えば、バックエンドのサーバーを立ち上げるための設定をしているserver/entrypoints/index.tsでは、デフォルトの状態だと以下のようになっています。

index.ts

import 'reflect-metadata'
import path from 'path'
import Fastify from 'fastify'
import helmet from 'fastify-helmet'
import cors from 'fastify-cors'
import fastifyStatic from 'fastify-static'
import fastifyJwt from 'fastify-jwt'
import { createConnection } from 'typeorm'
import server from '$/$server'
import ormOptions from '$/$orm'
import {
  API_JWT_SECRET,
  API_SERVER_PORT,
  API_BASE_PATH,
  API_UPLOAD_DIR,
  TYPEORM_HOST,
  TYPEORM_USERNAME,
  TYPEORM_PASSWORD,
  TYPEORM_DATABASE,
  TYPEORM_PORT
} from '$/service/envValues'

const fastify = Fastify()

fastify.register(helmet)
fastify.register(cors)
fastify.register(fastifyStatic, {
  root: path.join(__dirname, 'static'),
  prefix: '/static'
})
if (API_UPLOAD_DIR) {
  fastify.register(fastifyStatic, {
    root: path.resolve(__dirname, API_UPLOAD_DIR),
    prefix: '/upload',
    decorateReply: false
  })
}
fastify.register(fastifyJwt, { secret: API_JWT_SECRET })
server(fastify, { basePath: API_BASE_PATH })

createConnection({
  type: 'mysql',
  host: TYPEORM_HOST,
  username: TYPEORM_USERNAME,
  password: TYPEORM_PASSWORD,
  database: TYPEORM_DATABASE,
  port: Number(TYPEORM_PORT),
  migrationsRun: true,
  synchronize: false,
  logging: false,
  ...ormOptions
}).then(() => fastify.listen(API_SERVER_PORT))

また、デフォルトの状態の環境変数は以下のようになります。

.env

API_SERVER_PORT=39809
API_BASE_PATH=/api
API_ORIGIN=http://localhost:39809
API_JWT_SECRET=supersecret
API_USER_ID=id
API_USER_PASS=pass
API_UPLOAD_DIR=upload
TYPEORM_HOST=localhost
TYPEORM_USERNAME=root
TYPEORM_PASSWORD=
TYPEORM_DATABASE=hogehoge
TYPEORM_PORT=3306

おそらく、初期段階ではファイルのアップロードの設定や認証・認可の設定は不要になるかと思います。逆に、必要になるCORSやTypeORMの設定がされているので、この部分は工数削減になります。ただ、このように様々な箇所にデフォルトで設定がされているので、不要な設定がないかは確認しておいたほうが良いかと思います(他にも、TypeORMの場合、デフォルトでSubscriberの設定が追加されています)


最後に

管理画面の開発にfrourioを採用し、必要最低限のみの機能にはなりますが、想像以上のスピードでリリースすることができました。 ですが、まだまだfrourioの機能を活かせていない部分があり、特にaspidaSWR(stale-while-revalidate)、validationの機能を拡充したり、機能、非機能要件として以下の機能を実装していく必要があります。

  • NextAuthを利用した認証・認可
  • レバテック内部の各マイクロサービスとの連携
  • メール配信、予約配信
  • 記事投稿のためのリッチテキストエディター
  • ...etc

今回の記事で説明しきれていないfrourioの特徴やfrourio以外で困ったこと(Next.js、TypeORM固有の課題など)、frourioを利用したシステムアーキテクチャ(AWS)、また、今後上記の機能を実装していくなかで得られた知見などをまた別の機会に共有できたらなと思います。

参考記事

実践! Typescript で DDD - マイクロサービス設計のすすめ

対象読者

  • マイクロサービス化を検討しており、実際に作る場合の構成を参考にしたい。
  • ドメイン駆動設計について、基本的な用語の知識がある。
  • TypeScript を多少触ったことがある。理解がある。

はじめに

こんにちは。エンジニアの吉村です。 現在、弊社が運営する teratail というサービスに携わっており、CakePHP で動作しているモノリシックな既存サービスをマイクロサービスに移行するというプロジェクトを進行中です。

この記事では、実務を通して得た知見として、マイクロサービス化によりどんな恩恵があるのか、具体的にどのような構成で実装をしているのかについてご紹介します。

TL;DR

マイクロサービスのバックエンドサービスの実装に焦点を絞って、ドメイン駆動設計 + オニオンアーキテクチャをベースに設計をしました。 本記事では、具体的に「ユーザ新規登録処理」の実装をする場合を例にとり、実装手順をなぞりながら詳細を解説しています。 結論としては、従来の MVC ベースのアーキテクチャと比較して、メンテナンス性が高く技術的負債が生じづらい、スケーリングが容易な構成にできたのではないかと思います。その反面、簡単な処理の実装であっても記述量が多くなってしまうため、初期コストは割と高めな構成であるように思いました。

なぜマイクロサービス化を導入したのか

モノリシックサービスとマイクロサービスとで、どちらの構成が優れているかについては、実装するサービスの規模や様々な背景などによって左右される部分ではありますが、 今回のケースでは

  • 比較的規模の大きいシステムであること
  • サービスの改善を更に速度を上げて実施すべく、スケールしやすい構成にしておく必要があること
  • 弊社の他マイクロサービスと相互連携をしやすい構成にする必要があったこと

などの理由から、マイクロサービスとしてリプレースをしていく方針となりました。

マイクロサービスでシステムを実装をしていくにあたって、初期設計の段階で失敗してしまうとメンテナンスが困難なシステムができあがってしまう恐れがあるため、アーキテクチャの検討は注意深く行っていく必要がありました。 様々な文献で構成を調べる上で、特に気をつけたポイントは、なるべく技術的負債を回避でき、処理の把握が容易でメンテナンス性が高く、ミドルウェア等の環境の変更に強いシステムとなることでした。

それら検討の上で現在どのような構成で実装をしているか、簡単な処理での例を基にご紹介します。

サービスの構成

サービス全体の構成としては、フロントエンドサーバ、BFFサーバ、バックエンドサーバ と分けて実装をしていますが、 今回はバックエンドサーバのサービス構成に焦点を絞ってご紹介していきます。

構成図

サービスの構成は、オニオンアーキテクチャ[1]を参考に、以下の図の構成としています。

サービスの根幹となるドメイン層を中心に置いて、アプリケーション層、アダプタ層、インフラストラクチャ層と順に囲っていき、 外側の層から内側の層への単方向の依存のみ許可しています。※図の右側の各モジュールの詳細については後述します。

マイクロサービス化をするにあたり、ドメイン駆動設計を参考に、一つのアプリケーションを、例えばユーザ のアカウントについて管理する user サービスや、投稿された内容を管理する post サービスなど、それぞれ独立して成立するサービスとして分割していきます。このときに分割されたサービスの粒度を1単位としてサブドメインと呼ぶことにし、サブドメインごとに上図の構成のサービスを実装していきます。

ディレクトリ構成

ディレクトリの基本的な構成は以下の通りです。 以下の例ではサブドメインが user のみの構成となっていますが、サブドメインの数だけここに同様の構成のものが並びます。 src/shared には共通のコアロジックや、ベースクラスなどを配置しています。

src
├── server.ts
├── subdomains
│   └── user
│       ├── Entities
│       │   └── User.ts
│       ├── Events
│       │   └── UserCreated.ts
│       ├── Infrastructures
│       │   └── typeorm
│       │       └── User.ts
│       ├── Mappers
│       │   └── UserMap.ts
│       ├── Repositories
│       │   ├── UserRepository.ts
│       │   └── implements
│       │       └── TypeOrmUserRepository.ts
│       ├── Subscriptions
│       │   └── AfterUserCreated.ts
│       ├── UseCases
│       │   └── CreateUser
│       │       ├── CreateUserUseCase.ts
│       │       ├── CreateUserDTO.ts
│       │       ├── CreateUserErrors.ts
│       │       └── CreateUserController.ts
│       └── ValueObjects
│           └── UserName.ts
└── shared
    ├── core
    │   ├── AppError.ts
    │   ├── Guard.ts
    │   ├── Result.ts
    │   ├── UseCase.ts
    │   └── UseCaseError.ts
    └── domain
        ├── events
        │   ├── DomainEvents.ts
        │   ├── IDomainEvent.ts
        │   └── IHandle.ts
        ├── AggregateRoot.ts
        ├── Entity.ts
        ├── Identifier.ts
        ├── UniqueEntityID.ts
        ├── ValueObject.ts
        └── WatchedList.ts

実際に「ユーザ登録処理」を作ってみる

ドメイン層の定義

まず、ドメイン層として ValueObject と Entity の定義をしていきます。 ドメイン層はサービスの根幹となる部分であり、すべての処理はこのドメイン層で定義されたものを操作する形で実装していきます。

ValueObject

ValueObject は、Entityを構成するプロパティとして使用したり、レイヤ間のやり取りをする箇所などで用います。 ValueObject を用いるメリットとしては、対象の値が正しい形であるということが担保できるという点と、その値に対してのビジネスルールを豊かに表現できるという点があります。 オブジェクトを生成する際に必ずバリデーションロジックを通すように作ることができるため、ValueObject としてデータが存在している時点で、形式的に正しい値を持っているということが保証され、様々な箇所で形式チェックをする必要がありません。 また、例えば Email という ValueObject を作成したときに、対象の文字列からドメイン部分のみ抽出するというメソッドを用意したりなど、対象の値に対して何らかの加工をした形で取得をするといったことをまとめて定義しておくことができます。

以下の例では、ユーザの名前を表現する UserName ValueObject を定義しています。 入力された値が 3 文字以上 32 文字以下の場合はエラーを返す、簡単なバリデーションロジックを組み込んでいます。

// src/subdomains/user/ValueObjects/UserName.ts
export interface UserNameProps {
  value: string
}

export class UserName extends ValueObject<UserNameProps> {
  get value(): string {
    return this.props.value
  }

  private constructor(props: UserNameProps) {
    super(props)
  }

  private static isValidName(name: string) {
    const re = /^.{3,32}$/
    return re.test(name)
  }

  public static create(name: string): Result<UserName> {
    if (!this.isValidName(name)) {
      return Result.fail<UserName>(`Invalid Argument - userName:[${name}]`)
    }
    return Result.ok<UserName>(
      new UserName({ value: name })
    )
  }
}

※ Result

Result クラスは、成功形か失敗形かの状態を持たせた結果データを表すものとして実装しています。 成功形の場合は成功した値を取得することができ、失敗形の場合はエラーレスポンスを取得できます。どちらの状態であるかは isSucceedisFailure のプロパティの値で判別可能です。 上記 ValueObject の生成メソッドでこの戻り値を利用していますが、状態ごとの処理を記述する可能性のあるあらゆる箇所で利用しています。

Entity

用意した ValueObject をプロパティとして設定し、Entity を定義します。 Entity は固有の識別子として id を持っており、 id が一致している Entity は、プロパティが異なっている場合でも同一のものとして扱います。

// src/subdomains/user/Entities/User.ts
interface UserProps {
  name: UserName
  createdAt: DateTime
  updatedAt: DateTime
  deletedAt?: DateTime
}

export class User extends Entity<UserProps> {
  get name(): UserName {
    return this.props.name
  }
  
  get createdAt(): DateTime {
    return this.props.createdAt
  }

  get updatedAt(): DateTime {
    return this.props.updatedAt
  }

  get deletedAt(): DateTime | undefined {
    return this.props.deletedAt
  }

  private constructor(props: UserProps, id?: UniqueEntityID) {
    super(props, id)
  }

  public static create(props: UserProps, id?: UniqueEntityID): Result<User> {
    const user = new User({...props}, id)
    return Result.ok<User>(user)
  }
}

アプリケーション層の実装

次に、定義されたドメイン層の部品を操作して、ユーザ作成処理を記述するアプリケーション層の UseCase、 DTO、 RepositoryInterface を実装していきます。

DTO

まず、 DTO(Data Transfer Object)を定義します。 DTO は、UseCase の入出力パラメータを表します。 UseCase がリクエストとしてどういう形式を求めているか、レスポンスとしてどんなデータが返るのかを明確に定義しておくことで UseCase の仕様を把握しやすくするなる他、UseCase を実行する際にはこの定義に従って呼ぶようにすればよいため、 通信プロトコルが何なのか、または DomainEvent (後述します)の後続処理として実行されるのかなど、実行元が何であるかなどを意識することなく UseCase を定義することができるようになります。

// src/subdomains/user/UseCases/CreateUser/CreateUserDTO.ts
export interface CreateUserRequestDTO {
  name: string
}

export interface CreateUserResponseDTO {
  success: boolean
}

UseCase

UseCase は、ドメイン層を操作してデータの加工をし、Repository にデータを渡す処理や、Repository から取得したデータを返却する処理を記述していきます。 UseCase は、できるだけ単一の操作にのみ責任を持つように作るほうが良いです。例えば、エンティティを新規作成する UseCase を作成する場合は、データ作成の成否のみを(必要であれば作成したエンティティのIDのみあわせて)返却するよう実装し、エンティティ全体のデータを取得する UseCase は別途作成するようにしておきます。 このように、データの操作のみに責任をもつ Command 処理と、データの返却にのみ責任を持つ(データを操作しない) Query 処理に明確に分けて実装しておくと、それぞれの処理の最適化に注力しやすくなり、データの読み出しに関してはデータに副作用を与えないことが担保された Query 処理を使うことで、安全にシステムを利用することができるようになります。※CQRS(コマンドクエリ責務分離)[2]

以下の例では、ユーザデータを作成を実行して、成功か失敗かの bool 値のみを返す Command UseCase を実装しています。

// src/subdomains/user/UesCase/CreateUser/CreateUserUseCase.ts
type Response = Either<
  | CreateUserAlreadyExistsError
  | CreateUserFailedToCreateUserError
  | UnexpectedError
  | BadRequestError,
  Result<CreateUserResponseDTO>
>

export class CreateUserUseCase
  implements UseCase<CreateUserRequestDTO, Promise<Response>> {
  constructor(
    private userRepository: UserRepository
  ) {}

  async execute(req: CreateUserRequestDTO): Promise<Response> {
    const nameOrError = UserName.create(req.name)
    if (nameOrError.isFailure) {
      return failed(new BadRequestError(nameOrError.errorValue().value))
    }

    const name = nameOrError.getValue()
    const now = DateTime.utc()
    try {
      if (await this.userRepository.isExists(name)) {
        return failed(new CreateUserAlreadyExistsError(name))
      }

      const userOrError = User.create({
        name: name,
        createdAt: now,
        updatedAt: now
      })
      if (userOrError.isFailure) {
        return failed(new CreateUserFailedToCreateUserError())
      }

      const user = userOrError.getValue()
      await this.userRepository.save(user)

      return succeed(
        Result.ok<UserCreateResponse>({
          success: true,
        })
      )
    } catch (err) {
      return failed(new UnexpectedError(err.message))
    }
  }
}

※ Either

UseCase のレスポンスは、Either クラスで表現しています。 UseCase の結果値として、複数の Result オブジェクトが返る場合がありますが、それぞれが正常系か異常系かを振り分けて定義しています。 これにより、 UseCase の利用側はレスポンスを判別が成功系か異常系か判別して処理を記述しやすくなるほか、UseCase の定義書のように機能し、どんなパターンのレスポンスが存在するか一目で分かるようになる利点があります。

UseCaseError

UseCase の異常系レスポンスとして、UseCaseError を定義します。

// src/subdomains/user/UesCase/CreateUser/CreateUserError.ts
export class CreateUserAlreadyExistsError extends Result<UseCaseError> {
  constructor(name: UserName) {
    super(false, {
      message: `User name already exists. - name: ${name.value}`
    } as UseCaseError)
  }
}

export class CreateUserFailedToCreateUserError extends Result<UseCaseError> {
  constructor() {
    super(false, {
      message: "Failed to create user."
    } as UseCaseError)
  }
}

RepositoryInterface

UseCase 実装時点で、Repository にアクセスする必要がある箇所を記述する場合は、Repository の実装を抽象化した RepositoryInterface を作成し、抽象化された Repository へアクセスする形で実装していきます。※依存性逆転の原則[3]

これをする理由は、UseCase は本来、データを永続化する仕組みが何であるか、ORMとして何を使っているのかなどは意識する必要がなく、永続化データへの読み書きさえできれば Repository の具体的な実装を気にしなくても良いためです。 また、Repository を抽象化せずに具体実装に対してアクセスするようにしてしまうと、アプリケーション層がそれより外側のアダプタ層に依存する形での実装となってしまい、内側の層が外側の層に依存してはならないというルールに反してしまうため、これを逆転させる目的があります。

// src/subdomains/user/Repositories/UserRepository.ts
export interface UserRepository {
  isExists(name: UserName): Promise<boolean>
  save(user: User): Promise<void>
}

データ永続化処理実装

UseCase 実装の際に定義した RepositoryInterface の定義に基づき、DB 定義とデータ永続化の具体実装をしていきます。 従来型の MVC による実装などに慣れていると、まず DB の定義をしてから UseCase 処理の実装をしていきたくなるかもしれませんが、今回のアーキテクチャではそれとは逆で、ドメイン層とアプリケーション層を実装したあとに DB 定義を行います。 Repository は アプリケーション層・ドメイン層の処理の中で生成・更新された Entity を永続化し、永続化されたデータを Entity として返すことにのみ責任を持つように実装していきます。 こうすることでサービスのコアとなるロジックが DB の定義や ORM などに密接に依存してしまうような作りを回避することができます。

DBスキーマ定義

TypeORM を使用する場合を例に以下のようにモデルを定義します。

// src/subdomains/user/Infrastructures/typeorm/User.ts
import {
  Column,
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn
} from "typeorm";

@Entity()
export class User {

  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @CreateDateColumn({
    type: "datetime",
  })
  createdAt!: Date

  @UpdateDateColumn({
    type: "datetime",
  })
  updatedAt!: Date

  @DeleteDateColumn({
    type: "datetime",
    nullable: true,
  })
  deletedAt?: Date
}

Repository 実装

// src/subdomains/user/Repositories/implements/TypeOrmUserRepository.ts
import { createConnection, getRepository } from 'typeorm'
import { User } from '../../Infrastructures/typeorm/User'
import { UserMap } from '../../Mappers/UserMap'

export class TypeOrmUserRepository implements UserRepository {
  async isExists(name: UserName): Promise<boolean> {
    const connection = await createConnection()
    const userRepository = getRepository(User)

    const user = await userRepository.findOne({ name })

    await connection.close()

    return !!user
  }

  async save(user: User): Promise<void> {
    const connection = await createConnection()
    const userRepository = getRepository(User)

    await userRepository.save(UserMap.toPersistent(user))

    await connection.close()
  }
}

Mapper 実装

Entity <=> 永続化データ間の変換処理を Mapper として実装します。

// src/subdomains/user/Mappers/UserMap.ts
export interface UserRawProps {
  id: string
  name: string
  createdAt: Date
  updatedAt: Date
  deletedAt?: Date
}

export class UserMap implements Mapper<User> {
  public static toDomain(raw: UserRawProps): User {
    const userNameOrError = UserName.create(new UniqueEntityID(raw.name))
    if (userNameOrError.isFailure) {
      throw new Error(userNameOrError.error)
    }

    const userOrError = User.create(
      {
        name: userNameOrError.getValue(),
        createdAt: DateTime.fromJSDate(raw.createdAt),
        updatedAt: DateTime.fromJSDate(raw.updatedAt),
        deletedAt: raw.deletedAt
          ? DateTime.fromJSDate(raw.deletedAt)
          : undefined
      },
      new UniqueEntityID(raw.id)
    )

    if (userOrError.isFailure) {
      throw new Error(userOrError.error)
    }

    return userOrError.getValue()
  }

  public static toPersistent(userEntity: User): UserCreationParam {
    return {
      id: userEntity.id.toString(),
      name: userEntity.name.value,
      createdAt: userEntity.createdAt.toJSDate(),
      updatedAt: userEntity.updatedAt.toJSDate(),
      deletedAt: userEntity.deletedAt?.toJSDate()
    }
  }
}

通信インターフェース実装

ここまででユーザ登録処理の UseCase の実装が完了しました。次にこれを呼び出すインターフェースの実装をしていきます。 ここでは gRPC で実装する場合を例に実装していきます。

protocol buffer 定義

syntax = "proto3";
package sample.user.command_user;

service CommandUser {
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
}

message CreateUserRequest {
  string name = 1;
}

message CreateUserResponse{
  bool success = 1;
}

Controller 実装

// src/subdomains/user/UesCase/CreateUser/CreateUserController.ts
export class CreateUserController {
  constructor(
    private createUserUseCase: CreateUserUseCase
  )

  async createUser(
    req: CreateUserRequest,
    metadata?: Metadata
  ): Promise<CreateUserResponse> {
    const reqDTO = req as CreateUserRequestDTO
    const res = await this.createUserUseCase.execute(reqDTO)

    if (res.isFailed()) {
      const error = res.value
      switch (error.constructor) {
        case CreateUserAlreadyExistsError:
        case CreateUserFailedToCreateUserError:
        case UnexpectedError:
        case BadRequestError:
        default:
          return {
            success: false
          }
      }
    }

    return {
      success: true
    }
  }
}

これでユーザ登録処理の実装が完了しました。 gRPC で対象のインターフェースを呼び出すことで、DB に対象ユーザのデータが登録ができていることを確認することができます。

ユーザ作成直後に処理を実行したい場合

ユーザ登録処理が完了した直後、メールなどの通知を送信したりレポートデータを書き込みたいなどの後続処理を実装する必要が出てくるケースがあります。その場合は、既存の UseCase (今回でいうと CreateUserUseCase )に手を加えて実装はせず、別の UseCase として各処理を実装し、 DomainEvent を利用して処理を実行するのが望ましいです。

① CreateUserUseCase を実行 ② User エンティティを生成したときに UserCreated イベントを発行。データの永続化が完了した時点でイベントを発火させる。 ③ Subscription がイベントの発火を検知して、後続処理を実行する。

DomainEvent

User エンティティが生成される際に、対象の Entity 情報を EntityID でタグ付けした DomainEvent を発行します。 この時点では、 DomainEvent を発行しているだけで、まだ発火はしていません。

// src/subdomains/user/Entities/User.ts
- export class User extends Entity<UserProps> {
+ export class User extends AggregateRoot<UserProps> { 

    // ...

    public static create(props: UserProps, id?: UniqueEntityID): Result<User> {
      const user = new User({...props}, id)

+     // 新規作成時のみイベントを発行するため、 id が付与されていない場合に実行
+     if (!id) {
+       this.addDomainEvent(new UserCreated(user))
+     }

      return Result.ok<User>(user)
    }
  }

イベントの発火は、データの永続化の完了を検知したタイミングで実行します。 TypeORM の場合を例にとると、 Entity Listener を使って以下のように実装します。

// src/subdomains/user/Infrastructures/typeorm/User.ts
@Entity()
export class User {

  // ...

+ @AfterInsert()
+ dispatchAggregateEvents() {
+   const aggregateId = new UniqueEntityID(this.id);
+   DomainEvents.dispatchEventsForAggregate(aggregateId);
+ }
}

Subscription は、 DomainEvent の発火を検知して、DomainEvent 内の Entity 情報を使って後続処理を実行するように実装しておきます。その上で、 Subscription をサーバ起動時に常時監視状態にしておくことで、ユーザ登録完了後に後続処理を実行する実装の完了です。

おわりに

マイクロサービスのバックエンドサービスの実装に焦点を絞って、オニオンアーキテクチャをベースに設計をしました。 実際にこの構成を採用してシステムを実装した所感として、従来の MVC アーキテクチャなどの実装と比較して良かった点、気になる点をまとめると以下のとおりです。

良かった点

  • サービスのコアロジックを独立して実装できるため、ミドルウェアの変更など環境の変化に柔軟に対応可能。
  • 各実装レイヤーごとに明確に責務を割り振ることで、処理の流れを把握しやすい。
  • 一貫したルールでの実装を半ば強制することで、システムの規模が大きくなるにつれて処理が複雑になりすぎるということが起きづらい。
  • 機能の追加・削除が容易で、スケールしやすい。

気になる点

  • 簡単な機能であってもコードの記述量が多くなってしまうため、初期コストが高い。

冒頭で述べたように、運用面でのメリットは多くありますが、初期の実装にかかるコストは高いように思いました。 リリースまでのリソースに余裕があり、長期的に運用をしていく可能性のあるシステムには導入する価値があるように思いますが、スタートアップのような限られたリソースでとにかく早くリリースをしたいという場合は、慎重に採用の判断をしたほうが良い構成であると思います。

各項目の解説については詳細を割愛している部分も多々ありますが、全体像の大まかなイメージだけでも伝われば幸いです。

参考記事