🌎

SaaSのフロントエンドを0から立ち上げた話

2023/02/20に公開

こんにちは、nakamuyuです。

株式会社UPDATAでDataMageというサービスを開発しており、最近β版を公開しました🎉PMFに向けてチームみんなで奔走しています!

DataMageは2021年10月に立ち上がったプロダクトです。
私は1人目のフロントエンドエンジニアとして参画しており、ここまでほとんどの機能を1人で実装してきました。
最近、バックエンドエンジニアの方に機能の拡張を実装いただいたり、嬉しいことに2人目のフロントエンジニアの方の新たな採用が決まったので、このタイミングでこれまで作ってきたアーキテクチャやその思想を書き残しておこうと思います。

何かしらの新規プロダクトを立ち上げに携わっているエンジニアの役に立てれば幸いです。

技術スタック

  • ベース: Typescript, React, Vite
  • レンダリング: SPA (Single Page Application)
  • コンポーネントライブラリ: MUI (Material UI)
  • バックエンドとのやりとり
    • Hasura(GraphQL)
    • Hasura Actions(その他API)
  • Hosting: firebase
  • 静的解析ツール: eslint
  • フォーマッター: prettier
  • 開発環境: Docker x Dev Containers (vscode拡張機能)

アーキテクチャ

pages (router),components,store (Custom Hooks),graphql(repository)を主なディレクトリ構造としています。ぱっと見Next.js x Reduxっぽいディレクトリ構造なのかなと思いますがどちらとも使っていません。
基本的にはComponent -> Action -> Store -> ComponentというデータフローをReactHooksであるuseContextとuseReducerを使って組んでいます。

pages

当時軽くバズってたこともあってビルドツールにはViteを採用しており、Next.js等のフレームワークは入れていないためルーティングは自作しています。pages配下のファイルパスを拾って自動でルーティングを生成する仕組みを自作して、運用しています。pagesの中身はcomponents/template配下のテンプレートを呼び出してるだけになります。メインの責務はルーティングです。
(この辺りメンテナンスとか大変だし、Next.jsに移行しても良いかなと思っています。とはいえPMFするまではここの移行コストは本質的でない気がするので優先度は落としています。)

components

componentsの設計にはAtomic Designを採用しています。私が1人で開発していたこともあり、機能の開発速度の観点からコンポーネントライブラリであるMUI(Material UI)を導入しました。
とはいえMUI(ライブラリ)に依存した設計にはしたくなかったため、MUIを使用する場所を制限する必要がありました。そこでMUIコンポーネントの利用はAtomic DesignにおけるAtom(原子)の部分に限定し、いつでもコンポーネント単位でのリプレイスを可能に設計しました。
また、これはAtomic Designのコンセプトでもありますが、デザインの統一感をソースコードレベルで担保する上でもこの設計は良かったと思っています。コンポーネントライブラリはよしなにインターフェースが用意されている分、利用箇所を制限しないとデザインの統一感が徐々に損なわれていくと思います。

graphql

graphqlディレクトリは外部(API)との通信が責務です。詳細なディレクトリ構造としては次のような感じです。

graphql/
     ├ client.ts
   ├ datasources/
      ├ query/
         ├ get_datasource.ts
         └ get_datasource_list.ts
      ├ type/
         └ index.ts
   ├ users
   ...

DBのテーブルごとにディレクトリを分けていてその中にクエリと型定義ファイルを配置してます。
型定義ファイルではdatabaseのschemaでフロントからアクセス可能なものはここで定義しておきます。またリレーションテーブルについてもインターフェースに定義しておきます。リレーションテーブルの型自体はrelation_tableで定義しておき、importして使うことでプロダクト全体でschema変更に強い型定義ができます。

datasources/type/index.ts
import { IDbRelationTable } from '@/graphql/relation_table/type'
export interface IDbDatasource {
  id: number
  name: string
  type: number
  created_at: string
  updated_at: string
  relation_table: IDbRelationTable[]
}

以下のクエリだとリレーションのrelation_tableカラムを取得してないですが、取得先(store)で欲しい型に変換して丸め込むのであまり気にしなくて良いと思ってます。

get_datasource.ts
import { executeQuery } from '@/graphql/client'
import { IDbDatasource } from '@/graphql/datasource/type'
const GET_DATASOURCE = `
  query GetDatasource($id: bigint!) {
    datasources(where: {id: {_eq: $id}}) {
      id
      name
      type
      updated_at
      created_at
    }
  }
`
interface IVariablesQuery {
  id: number
}
export const getDatasource = async (variables: IVariablesQuery): IDbDatasource => {
  const res = await executeQuery('getDatasource', GET_DATASOURCE, variables)
  return res.datasources[0] as IDbDatasource
}

Genericsの利用(型駆動開発)

ここではGenericsをベースとした型駆動で開発することでスケーラビリティのあるコードができる。という話をさせていただきます。

このセクションは少し長めになってしまったのですが、個人的にビジネス観点も含めて最も書きたかった部分なので分厚めになっております🙇
なるべく簡素に書くので、読んでいただけると幸いです。

Genericsの利用の背景

DataMageでは「Salesforce等のSaaSやMySQL等のDBにあるデータ(データソース)をBigQueryに取り込む」という機能があります。そのためユーザーさんに接続情報を入力してもらいますが、接続先によって入力するデータ項目が変わります。
こういった要件はSaaS開発ではよく出てくるんじゃないかと思っています。
Genericsはこういった場面で有効です。

実装例

// 1. 全てのデータソースのベースとなる型枠を定義
interface IDatasourceConfigBase<D extends 'MySQL' | 'Salesforce', C extends TDatasourceConfig | undefined> {
  datasourceType: D
  config: C
}

// 2. 実際にSalesforceの接続設定を保持する型を生成
type ISalesforceConfig = IDatasourceConfigBase<'Salesforce', ISalesforceConfig>

// 3. データソースのConfigに関するUnion型を生成
type TDatasourceConfig = IMySQLType | ISalesforceType ...

// 4. データソース共通で必要なid, nameという2つのプロパティと3.で作ったTDatasourceConfigの交差型を生成
type TDatasource = {id: number ;name: string} & TDatasourceConfig

こうする事で1~4にかけて型が大きくなっていることがお分かりいただけると思います。言い換えると各型は抽象度が異なります。

これによりそれぞれ抽象化された概念に対してロジックを実装することが可能になります。

  • "SalesforceConfig"に関するロジック(入力フォームなど)はISalesforceConfigをinterfaceとするコンポーネントやfunctionに閉じ込めることができる。
  • "データソースのConfig"に関するロジックはTDatasourceConfigをinterfaceとするコンポーネントやfunctionに閉じ込めることができる。
  • "データソース全体"に必要なロジックは以下同文...
    (多分俗にいう「インターフェース分離の原則:SOLID原則」ってやつです。)

恩恵

結果として機能の拡張が圧倒的にシンプルに実装できるようになります。
DataMageだとクライアントごとに接続したいデータソースが異なるので、ある日突然「XXX広告のデータをデータソースとして追加したい」みたいな要件が出てきます。
そんな時にデータソース全体のロジックなんてものは意識せず、”XXX広告に関するロジック”にフォーカスして実装することができます。

直近でGoogle広告をデータソースとして追加した時のプルリクです。

GoogleAdsと名前に含まれるファイルが追加されてるか、既存のコードへの追記箇所もGoogleAdsというキーに紐づいているものだけが差分になっています。
もちろんこのプルリク内では一切データソース全体のロジックには触れていません。
(多分俗にいう「開放閉鎖の原則:SOLID原則」ってやつです。)

何が嬉しいの?

これはバックエンドエンジニアの方が実装してくれたプルリクなんですが、質問はほとんど、というかゼロのままここまで実装いただけました。これは実装内容がそのドメインで閉じていることで余計なことに脳のリソースを奪われない。さらに型はlinterがチェックしてくれるので、エラー駆動で実装箇所を教えてくれます。
実際DataMageでは以下のようなunion型でデータソースタイプを定義してるんですが、ここに新たなタイプを追加すると実装すべき箇所の全てで型エラーが出るようになっています。

type TDatasourceTypes = 'MySQL' | 'Salesforce' | ...

スタートアップでは正社員エンジニアが1人2人いて、あとは業務委託や副業の方にお願いしているケースはよくあります。本業をお持ちの方だと同期的コミュニケーションができず、複雑な内容の質問をテキストで投げるのも少し難しく時間だけが経っていく。みたいなことは実は結構あるあるだと思ってます。そういったエンジニアの方々のパフォーマンスを最大化させる意味でもこの設計は非常に良かったと思っています。

スタートアップにおいて、「とりあえずプロトタイプ(とりあえず動く)機能を作れ。」的な話がありますが、将来的なスケールを考えると、ここの設計には多少コストをかける価値があると思っています。むしろここを蔑ろにすると型アサーションが横行したり、コードが複雑になりやすいので、「早く作る」という点においても型設計はちゃんとやっておくべきだと私は考えています。

何より、エンジニアにとって型含めてこの辺の設計してる時が一番楽しくないですか?😎

DX (Developer Experienceの方)

静的解析ツール / フォーマッター

前述した通り、静的解析ツールにeslint、フォーマッターにはprettierを採用しています。vscodeの拡張機能含めてこの辺りの設定は結構様々な記事があるのでここでは省略します。

DataMageでは、ここで定義したコーディング規約を守るためhuskylint-staged
を導入してます。こちらも割と定番ではありますが、知ってると知らないではDXに結構影響するので、知らない方はぜひ参考にしてください。
huskyはコミットやプッシュ時に任意のコマンドを走らせるパッケージで、lint-stagedはステージングエリアに上がっているファイルに対してlinterを走らせるパッケージです。

これを用いてコミット前に1.自動フォーマット (prettier --write), 2.スタイル違反などの警告を自動修正(eslint --fix), 3.linterの実行を行います。もし規約違反があれば、コミットはできないのでプルリクに上がってくる時点で規約が守られてるものになります。これだけでレビュー工数は結構小さくなります。

開発環境

DataMageフロントのローカル開発環境はDocker x Dev Containers (vscode拡張機能)を採用しています。
Dockerは特に説明の必要がないかなと思いますが、特徴的なのはDev Containersを利用しているところです。Dev ContainersはvscodeでDockerコンテナにリモート接続し、Dockerコンテナ内で開発を行える拡張機能です。
Dev Containersの素晴らしい点はvscodeの拡張機能をDockerコンテナの中にインストールして使える点です。開発に必要な拡張機能をそのアプリケーションの開発コンテナ内に納めれるという点でローカル環境が無駄に汚されない上、それらの拡張機能を事前にjsonに定義しておくことができるので前述したeslintprettierなどの拡張機能もコンテナを立ち上げただけでインストールされることになります。
これにより、エディター環境までも全エンジニアで共有することができます。

そのため、環境構築ドキュメントには

1. .envファイルを配置
2. GitHubからCloneしてくる
3. VSCode画面左下に表示されている><マークを押し、Reopen in Containerを選択 (Dev Containersを使ってコンテナ起動)

の3行が書かれています。
(一応他のエディターを希望する人もいそうなので、docker-composeでも立ち上げれるようにしてます。)
たったこれだけで完全に同じ環境を再現できるのはなかなか良い開発者体験だなと思います。

終わりに

ここまで読んでいただきありがとうございます。
今回はまとめとして書きましたが、この記事の内容はこれまで何度か再設計やリファクタを行い、現状行き着いた形になります。現状の設計もまだまだ改善の余地があり、個人的に直したい点はたくさんあります。
とはいえ、スタートアップにおいてどこまで再設計やリファクタに工数を割けるかは経営者の考え方やキャッシュ状況も影響するかと思います。(ちなみにDataMageチームはめちゃくちゃその辺寛容です。)
そのためなるべく初手からより良い設計でプロダクトを作ることはスタートアップにおいてかなり重要な要素だと思います。
この記事が少しでもそういったエンジニア方の設計の参考になれば幸いです。

よしこさんの記事や他の記事も読ませていただきましたが、スタートアップのフロントエンジニアは機能開発に加えて環境整備のために様々な取り組みをされていてとても参考になりました。負けじと頑張らねばと身が引き締まります💪
また共有できることは随時記事にまとめていこうと思います!

どんなことでも良いので、興味を持ってくださった方はお気軽に TwitterDMもしくはMeetyでご連絡ください!是非お話ししましょう!!

  • もっと詳細に聞きたい
  • 幅広くフロントについて
  • 新規事業(SaaS)について
  • データ活用について
  • DataMageについて
  • ゴルフ一緒に行きましょう⛳️
  • ポーカー行きましょう♤
GitHubで編集を提案

Discussion