newmo 技術ブログ

技術で地域をカラフルに

monorepo内でのパッケージのバージョンを1つだけに統一するOne Version Ruleをpnpm catalogで実装する

newmoでは、フロントエンド、バックエンド、iOSやAndroidなどのモバイルアプリをすべて同じリポジトリで管理するmonorepoを採用しています。 monorepoを採用することで、アプリケーション間で共通のコードを共有することができたり、CIの管理が楽になったり、他のチームのコードを見るのにわざわざリポジトリをcloneする必要がなくなります。 また、monorepoを採用することで、アプリケーションが利用しているパッケージ(ライブラリやツール)のバージョンを1つだけにするOne Version Ruleが実装できます。

One Version Rule

One Version Ruleは、monorepo内のパッケージのパッケージのバージョンを1つだけにするルールです。

One Version Ruleでは、monorepo内のアプリケーションが依存するパッケージは1つのバージョンだけにします。 たとえば、アプリケーションAがpackageXのバージョン 1.0.0 を使っているとき、アプリケーションBもpackageXのバージョン 1.0.0 を使うように統一します。

これによって、あるパッケージが1つのバージョンだけに集約されるので、メンテナンス性がよくなったり、パッケージのアップデートに関するセキュリティ的な問題に対処しやすくなったり、Diamond dependencyのようなパッケージ同士の依存におけるバージョンのConflictが起きにくくなります。

また、副作用として新しいパッケージを入れることに慎重となるため、同じ機能を持つ異なるパッケージが入りにくい傾向があります。 雑にパッケージの依存を増やすコストが高くなるため、newmoではDesign Docを書いて議論してからパッケージを追加することが多いです。

一方で、One Version Ruleを実践するには、当たり前ですがパッケージのバージョンを1つに統一する必要があります。そのため、依存するパッケージのバージョンを1つに統一するための方法を考える必要があります。 このパッケージのバージョン管理方法は言語により異なるため、言語ごとに適切な方法を選択する必要があります。

この記事では、フロントエンドで利用するnpm Registryのパッケージのバージョンを1つに統一するOne Version Ruleを実装する方法について紹介します。

pnpm catalogを使ったOne Version Ruleの実装

newmoでは、npmのパッケージ管理ツールとしてpnpmを利用しています。 そして、pnpmのCatalogs機能を使ってOne Version Ruleを実装しています。

pnpm catalogとは

pnpm catalogは、pnpm 9.5で追加された機能で、依存する複数のパッケージに名前をつけて管理したり、パッケージの依存をカタログ的に一箇所で管理できる仕組みです。

pnpmのworkspace(複数のパッケージ = ここではアプリケーションを統合的に扱う仕組み)を定義するpnpm-workspace.yamlに、catalogを定義できるようになっています。 pnpm-workspace.yamlcatalog:には、パッケージ名とそのバージョンを定義できます。

pnpm-workspace.yaml:

packages:
  - packages/*

# Define a catalog of version ranges.
catalog:
  react: ^18.3.1
  redux: ^5.0.1

catalog: 直下のパッケージはデフォルトのカタログとして扱われ、アプリケーションのpackage.jsonからは catalog:(名前がない場合はデフォルトカタログという意味となる)で参照できるようになります。

アプリケーションのpackage.json:

{
  "name": "@example/app",
  "dependencies": {
    "react": "catalog:",
    "redux": "catalog:"
  }
}

さらにcatalogにはNamed Catalogsという機能もあり、複数のパッケージとバージョンをまとめたものに対して名前をつけて管理することができます。

次の例では、react17react18という名前のcatalogを定義しています。

catalogs:
  # Can be referenced through "catalog:react17"
  react17:
    react: ^17.0.2
    react-dom: ^17.0.2

  # Can be referenced through "catalog:react18"
  react18:
    react: ^18.2.0
    react-dom: ^18.2.0

同じようにアプリケーションからは catalog:react17catalog:react18 で参照できます。

{
  "name": "@example/components",
  "dependencies": {
    "react": "catalog:react18",
  }
}

この機能を使うことで、monorepoにある全てのパッケージの名前とバージョンがpnpm-workspace.yamlという一つのファイルで管理できるようになります。

One Version Ruleでは、monorepoでは1つのバージョンを扱うので基本的にデフォルトカタログ(catalog:)にほとんどのパッケージを記述することになります。

パッケージをアップデートするときに、アプリケーションのコードも必要な場合があります。 複数のアプリケーションが依存しているパッケージの場合、同時にアプリケーションを修正することが難しいケースもあります。 その場合は、例外的にNamed Catalogsを使ってバージョンを分けることで、段階的にアップデートを進めることも可能です。

ここまでは、pnpmのcatalog機能の紹介でしたが、実際にOne Version Ruleをやるにはこのルールに強制力が必要です。 newmoのmonorepoでは、pnpmのHooks機能を使ってこのルールに強制力を持たせています。

pnpmのHooksを使ったOne Version Ruleの実装

pnpmのHooks機能を使うことで、依存するパッケージのバージョンを1つに統一するOne Version Ruleを実装することができます。

pnpmのHooks機能は、package.jsonの依存を解析したタイミング(readPackage)と依存関係が全て解決したタイミング(afterAllResolved)にフックする処理を.pnpmfile.cjsファイルに記述できます。

実現したいOne Version Ruleをpnpmのレベルまで落とすと、次のようなチェックをすれば良いことがわかります。

  1. アプリケーションが依存するパッケージのバージョンは workspace:* または catalog: で指定する
    • アプリケーション側には直接バージョンを指定できなくします
  2. { "<name>": "catalog:" }とアプリケーションで指定されたパッケージの実際のバージョンが pnpm-workspace.yaml に記載されている
    • pnpm-workspace.yamlで全てのパッケージのバージョンを管理するようにします
    • 基本的にはデフォルトカタログ(catalog:)にパッケージとバージョンを定義して、1つのバージョンを使うようにします
    • 例外としてnamed catalogを使うことで、複数のバージョンが存在することは許容します
  3. monorepo内のパッケージとnode_modulesのパッケージを区別できるような名前をつける
    • これはreadPackageがmonorepo内外の両方package.jsonの解析のタイミングで呼ばれるため、区別するのに必要です
    • newmoでは @newmo-app/ で始まるパッケージをmonorepo内のパッケージとして扱っています
  4. バージョンは必ずPinされたバージョンを使うようにする
    • lockファイルでバージョンは固定はされますが、pnpm-workspace.yamlを見たときにバージョンがわかるようにPinされたバージョンを使うようにしています
  5. このルールを満たさない時は、自動的に修正コマンドをエラーに表示する
    • このルールを満たさないときに手動で修正することは可能ですが、自動的に修正コマンドを表示することで、修正の手間を減らすことができます
    • 一般的とは言えないルールなので、普通のパッケージ管理ツールを使うのと同じぐらい簡単に扱えるようにする必要があります

このルールを実装した.pnpmfile.cjsは、次のようになっています。 MONOREPO_PREFIXを変更すれば、大体そのまま利用できるようになっています。

.pnpmfile.cjs (クリックで開く)

/**
 * # One Version Rule Implementation
 * **社内Notionへのリンク**
 **/
const rootPkg = require("./package.json");
const fs = require("node:fs");
const path = require("node:path");
const rootDir = __dirname;
/**
 * Prefix for all monorepo internal packages
 * monorpo内部のアプリケーションやmonorepo内のworkspaceのパッケージは、このprefixで始まるようにする
 * @type {string}
 */
const MONOREPO_PREFIX = "@newmo-app/";
const pnpmCatalogYamlFile = path.join(rootDir, "pnpm-workspace.yaml");
const pnpmCatalogYaml = fs.readFileSync(pnpmCatalogYamlFile, "utf-8");
const isMonorepoPackage = (pkgName) => {
  return pkgName.startsWith(MONOREPO_PREFIX);
};
/**
 * Pin the version of the package
 * @example
 * pinVersion("^1.0.0") // => "1.0.0"
 * pinVersion("~1.0.0") // => "1.0.0"
 * pinVersion("1.0.0") // => "1.0.0"
 * @param {string }version
 * @return {string}
 */
const pinVersion = (version) => {
  if (version.startsWith("^") || version.startsWith("~")) {
    return version.slice(1);
  }
  return version;
};
const isPackageIncludedInCatalog = (pkgName) => {
  // "pkg": version
  // or
  // pkg: version
  const pkgAndVersionPattern = new RegExp(String.raw`"?${pkgName}"?: \d+\.\d+\.\d+`);
  if (pkgAndVersionPattern.test(pnpmCatalogYaml)) {
    return true;
  }
  // npm alias pattern
  // pkg: npm:...
  // e.g "@types/react": npm:types-react@19.0.0-rc.0
  const pkgAndVersionPatternWithNpm = new RegExp(String.raw`"?${pkgName}"?: npm:[\w-]{1,32}@`);
  return pkgAndVersionPatternWithNpm.test(pnpmCatalogYaml);
};
/**
 * Check if all packages are prefixed with {@link MONOREPO_PREFIX}
 */
const assertMonorepoPackageNameRule = (lockfile) => {
  // get all packages in the monorepo
  const pkgPathList = Object.keys(lockfile.importers).map(pathFromRoot => {
    return path.join(path.resolve(rootDir, pathFromRoot), "package.json");
  });
  // check if all packages are prefixed with
  for (const pkgPath of pkgPathList) {
    const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
    if (!isMonorepoPackage(pkg.name)) {
      throw new Error(`Found invalid package name: ${pkg.name}
            
Please make sure that all internal packages are prefixed with ${MONOREPO_PREFIX}

詳細は **社内Notionへのリンク** を参照してください。
`);
    }
  }
};
/**
 * Check if the package is not violating the One Version Rule
 * - All dependency versions should be pinned
 * - All `pnpm.overrides` versions should be pinned
 * @param pkg
 */
const assertRootPackage = (pkg) => {
  if (pkg.name !== rootPkg.name) {
    throw new Error(`Invalid argument: ${pkg.name}. This function only accepts root package.json`);
  }
  /**
   * @typedef {{depName:string; depVersion:string; depType: string; errorType: "invalid-semver-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  // all dependency versions should be pinned in root package.json
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // if depVersion is not pinned
        if (depVersion !== pinVersion(depVersion)) {
          errorPackageSet.add({
            depName, depVersion, depType, errorType: "invalid-semver-version"
          });
        }
      });
    }
  });
  // pnpm.overrides should be pinned
  if (pkg?.pnpm?.overrides) {
    Object.entries(pkg.pnpm.overrides).forEach(([depName, depVersion]) => {
      if (depVersion !== pinVersion(depVersion)) {
        errorPackageSet.add({
          depName, depVersion, depType: "pnpm.overrides", errorType: "invalid-semver-version"
        });
      }
    });
  }
  // throw error if there is any error
  if (errorPackageSet.size !== 0) {
    const commands = Array.from(errorPackageSet).map((errorPackage) => {
      if (errorPackage.errorType === "invalid-semver-version") {
        if (errorPackage.depType === "pnpm.overrides") {
          return `npm pkg set 'pnpm.overrides.${errorPackage.depName}'='${pinVersion(errorPackage.depVersion)}'`;
        }
        return `npm pkg set '${errorPackage.depType}.${errorPackage.depName}'='${pinVersion(errorPackage.depVersion)}'`;
      }
    }).join("\n");
    throw new Error(`Found invalid dependency versions in root package.json

This monorepo enforces all dependencies to be pinned version in root "package.json".
Please run the following commands to fix the issue:

\`\`\`
cd ${rootDir}
${commands}
pnpm install
\`\`\`
 
詳細は **社内Notionへのリンク** を参照してください。
`);
  }

};

/**
 * Check if the package is not violating the One Version Rule
 * @param pkg
 */
const assertWorkspacePackage = (pkg) => {
  /**
   * @typedef {{depName:string; depVersion:string; depType:string; packageName:string; errorType: "non-catalog" | "invalid-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // Skip monorepo packages
        // e.g. "@monorepo/package-name" -> "@monorepo/shared-lib"
        if (isMonorepoPackage(depName)) {
          return;
        }
        // check if the dependency is defined in catalog
        if (!isPackageIncludedInCatalog(depName)) {
          errorPackageSet.add({
            depName, depVersion, depType, packageName: pkg.name, errorType: "non-catalog"
          });
        }
        // check if the dependency version is defined as workspace:*
        if (!depVersion.startsWith("workspace:") && !depVersion.startsWith("catalog:")) {
          errorPackageSet.add({
            depName, depVersion, depType, packageName: pkg.name, errorType: "invalid-version"
          });
        }
      });
    }
  });

  if (errorPackageSet.size !== 0) {
    /**
     * @param {RuleError}
     */
    const fixCommand = ({ depName, depVersion, depType, packageName, errorType }) => {
      if (errorType === "non-catalog") {
        // If already defined as workspace:*, then get latest version from npm
        if (depVersion.startsWith("workspace:") || depVersion.startsWith("catalog:")) {
          // use yq to append `depName: {latest}}` to "catalog" field
          return `yq -i '.catalog += {"${depName}": "$(npm info ${depName} version)"}' "${pnpmCatalogYamlFile}" # ${packageName}`;
        }
        // If not `depName: version` to pnpm-workspace.yaml
        return `yq -i '.catalog += {"${depName}": "${pinVersion(depVersion)}"}' "${pnpmCatalogYamlFile}" # ${packageName}`;
      }
      if (errorType === "invalid-version") {
        return `npm --prefix=$(find . -name package.json | xargs grep -l '"name": "${packageName}"' | xargs dirname) pkg set '${depType}.${depName}'='catalog:'`;
      }
    };
    const commands = Array.from(errorPackageSet).map((errorPackage) => {
      return fixCommand(errorPackage);
    }).join("\n");

    const isAddNewCatalog = Array.from(errorPackageSet).some((errorPackage) => {
      return errorPackage.errorType === "non-catalog";
    })

    throw new Error(`Found packages that violate the rule

This monorepo enforces all dependencies to be defined in root "package.json" and pnpm catalogs.
Please run the following commands to fix the issue:

yq コマンドに依存しているので、 "brew install yq" で事前にインストールしてください

\`\`\`
cd ${rootDir}
${commands}
pnpm install
\`\`\`

${isAddNewCatalog ? "新しいパッケージがpnpm-workspace.yamlのcatalogに追加されます。コマンド実行後に、pnpm-workspace.yamlに追加された依存を適切なグループに移動してください" : ""}
詳細は **社内Notionへのリンク** を参照してください。

`);
  }
};

function afterAllResolved(lockfile, context) {
  assertMonorepoPackageNameRule(lockfile);
  return lockfile;
}

/**
 * Read package.json and check if it violates the Mono Version Rule
 * @param pkg
 * @param context
 * @returns {*}
 */
function readPackage(pkg, context) {
  // pnpm create does not have package name
  const isPnpmCreate = pkg.name === undefined;
  if (isPnpmCreate) {
    return pkg;
  }
  // SKip outside monorepo packages
  if (!isMonorepoPackage(pkg.name)) {
    return pkg;
  }
  // Check root package
  if (pkg.name === rootPkg.name) {
    assertRootPackage(pkg);
    return pkg;
  }
  // Check workspace package
  assertWorkspacePackage(pkg);
  return pkg;
}

module.exports = {
  hooks: {
    readPackage,
    afterAllResolved
  }
};

この.pnpmfile.cjsは、pnpm install時に実行され、依存するパッケージのバージョンを1つに統一するOne Version Ruleを実装しています。

たとえば、次のようにアプリケーションに直接バージョンを指定した状態で pnpm install を行うとエラーが発生します

  "devDependencies": {
    "@playwright/test": "catalog:",
    "@types/node": "catalog:",
    "typescript": "catalog:",
    "jquery": "^3.7.1"
  },

実行時のエラーメッセージには、このエラーを修正するコマンドも表示されるので、コマンドを実行することでエラーを修正することができます。

pnpm: Found packages that violate the rule

This monorepo enforces all dependencies to be defined in root "package.json" and force the version via pnpm catalogs
Please run the following commands to fix the issue:

yq コマンドに依存しているので、"brew install yq" で事前にインストールしてください

```
cd /path/tp/newmohq/newmo-app
yq -i '.catalog += {"jquery": "3.7.1"}' "/path/tp/newmohq/newmo-app/pnpm-workspace.yaml" # @newmo-app/application-x
npm --prefix=$(find . -name package.json | xargs grep -l '"name": "@newmo-app/application-x' | xargs dirname) pkg set 'devDependencies.jquery'='catalog:'
pnpm install
```

詳細は **社内Notionへのリンク** を参照してください。

pnpm installでOne Version Ruleのチェックが行われるので、自動的にCIでも落ち、また開発者のローカルでもすぐエラーがわかるようになっています。

これによってnewmoでは、monorepo内の全てのパッケージのバージョンを1つに統一するOne Version Ruleを実装しています。

Note: Sherifのようなmonorepoに特化したLinterなどもありますが、.pnpmfile.cjsで実装するメリットは他のツールを増やす必要がない点にあります。

参考: newmoのpnpm catalog

参考までに、newmoのpnpm-workspace.yamlに記載されているパッケージのカタログを紹介します。 ここに書かれているパッケージが、現在時点(2024-08-30)でのフロントエンドで利用しているパッケージの一覧です。

packages:
  - ... 色々な内部パッケージ ...
catalog:
  # ------------------------------
  # Browser Runtimeで利用するその他の依存
  # カテゴライズが難しいが、ブラウザのRuntimeに含まれるような依存
  # ------------------------------
  # LIFFのSDK
  # Design Docs: **Design Docへのリンク**
  "@line/liff": 2.24.0
  # アニメーションを再生するプレイヤーのライブラリ
  # https://rive.app/
  "@rive-app/react-canvas": 4.12.0
  # ------------------------------
  # React関係
  # ------------------------------
  react: 19.0.0-rc-935180c7e0-20240524
  react-dom: 19.0.0-rc-935180c7e0-20240524
  "@types/react": npm:types-react@19.0.0-rc.0
  "@types/react-dom": npm:types-react-dom@19.0.0-rc.0
  # ReactのUIコンポーネントライブラリではReact Ariaを利用している
  # https://react-spectrum.adobe.com/react-aria/index.html
  # Design Doc: **Design Docへのリンク**
  react-aria-components: 1.2.1
  # ------------------------------
  # Next.js関係
  # Design Doc: **Design Docへのリンク**
  # ------------------------------
  next: 15.0.0-rc.0
  "@next/third-parties": 14.2.5
  # ------------------------------
  # Panda CSS関係
  # https://pandacss.com/
  # Design Doc: **Design Docへのリンク**
  # ------------------------------
  "@pandacss/dev": 0.44.0
  # 直接postcssは利用していない、Panda CSSをNext.jsと連携させるために利用している
  postcss: 8.4.41
  # ------------------------------
  # Storybook関係
  # Design Doc: **Design Docへのリンク**
  # ------------------------------
  storybook: 8.2.9
  "@storybook/addon-essentials": 8.2.9
  "@storybook/addon-interactions": 8.2.9
  "@storybook/addon-links": 8.2.9
  "@storybook/addon-storysource": 8.2.9
  "@storybook/blocks": 8.2.9
  "@storybook/react": 8.2.9
  # VRTには https://www.chromatic.com/ を利用している
  chromatic: 11.7.1
  "@chromatic-com/storybook": 1.6.1
  # StorybookにはViteを利用しているため、ViteのReactプラグインを入れてる
  "@storybook/react-vite": 8.2.9
  "@vitejs/plugin-react": 4.3.1
  # ------------------------------
  # Vite関係
  # https://vitejs.dev/
  # ------------------------------
  vite: 5.4.2
  # Unit TestはViteを利用している
  vitest: 2.0.3
  # ------------------------------
  # Playwright関係
  # https://playwright.dev/
  # ------------------------------
  # Integration TestはPlaywrightを利用している
  "@playwright/test": 1.46.0
  # 静的なHTMLをテストで利用する時に、ローカルサーバとしてserveを利用している
  serve: 14.2.3
  # ------------------------------
  # GraphQL関係
  # https://graphql-code-generator.com/
  # codegenのpluginやfake serverなど
  # GraphQLのruntimeでも必要な依存はここに記載する
  # ------------------------------
  graphql: 16.8.1
  "@graphql-typed-document-node/core": 3.2.0
  # GraphQLのクラインアントはApollo Clientを利用している
  "@apollo/client": 3.10.6
  # GraphQLのdevDependenciesはここに記載する
  "@graphql-codegen/cli": 5.0.2
  "@graphql-codegen/client-preset": 4.2.5
  "@newmo/graphql-codegen-plugin-typescript-react-apollo": 1.2.2
  # Fake Serverはローカル開発時に利用している
  # Design Doc: **Design Docへのリンク**
  "@newmo/graphql-fake-server": 0.11.0
  "@newmo/graphql-codegen-fake-server-client": 0.11.0
  # ------------------------------
  # ESLint関係
  # https://eslint.org/
  # ESLintの設定は/eslint.config.jsにまとめられている
  # ------------------------------
  eslint: 8.57.0
  typescript-eslint: 7.13.1
  eslint-config-next: 15.0.0-rc.0
  eslint-plugin-playwright: 1.6.2
  eslint-plugin-prettier: 5.1.3
  eslint-plugin-react: 7.34.1
  eslint-plugin-react-hooks: 4.6.2
  eslint-plugin-storybook: 0.8.0
  "@vitest/eslint-plugin": 1.0.4
  "@pandacss/eslint-plugin": 0.1.9
  "@next/eslint-plugin-next": 14.2.4
  "@graphql-eslint/eslint-plugin": 3.20.1
  # ------------------------------
  # secretlint関係
  # https://github.com/secretlint/secretlint
  # 機密情報がコミットされたらCIで落とすために入れている
  # ------------------------------
  secretlint: 8.2.4
  "@secretlint/secretlint-rule-preset-recommend": 8.2.4
  # ------------------------------
  # TypeScript関係
  # https://www.typescriptlang.org/
  # TypeScriptのcompiler pluginやtsconfigに関係するもの
  # ------------------------------
  typescript: 5.5.2
  # ------------------------------
  # Formatter関係
  # ------------------------------
  prettier: 3.2.5
  # ------------------------------
  # スクリプト/ツール関係
  # script/やcmd/などで利用する開発用のスクリプトやツールで利用するもの
  # ------------------------------
  glob: 10.3.12 # TODO: Node.js 22ではネイティブで利用できるので不要になる
  # ------------------------------
  # その他の開発系(devDependencies)の依存
  # driver-webでcloudflare pagesへデプロイするのに利用
  # ------------------------------
  wrangler: 3.57.1
  # ------------------------------
  # その他の@types
  # グルーピングされないtypesはここに記載する
  # ------------------------------
  "@types/node": 20.12.7
  # ------------------------------
  # ここから↓↓↓は、まだカテゴライズされていない依存が列挙されています
  # yqで追加した時はここに記載されるので、適切なカテゴライズをしてください。
  # ------------------------------

すでに複数のウェブアプリケーションが稼働していますが、依存はかなり最小限にしていて、また新しいパッケージを追加する際にはDesign Docを書いて議論することが多いです。 Design Docでは、導入する目的/目的外、他の選択肢との比較、メリット/デメリット、Tier(フロントエンドの移り変わりは早すぎるのかを参考に)などを議論しています。 いわゆるArchitecture Decision Record(ADR)のようなものですが、新しいパッケージを追加する際には、このような議論を行うことでなぜそのパッケージを導入するのかを明確にしています。 カタログにDesign Docへのリンクを入れることで、後から入った人もなぜそのパッケージを使っているかをわかるようにしています。

こうした議論をするのは、一度入れたパッケージを削除するのが難しいからです。 パッケージの扱い(Tier)について話すのも、どれぐらい該当のパッケージに依存してアプリケーションを作るかを関係者で認識を揃えるためです。

まとめ

newmoのフロントエンドでは、pnpmのcatalog機能を使ってOne Version Ruleを実装しています。

One Version Ruleは、monorepo内の全てのパッケージのバージョンを1つに統一するルールです。 これによって、パッケージの一覧性がよくなり、パッケージのアップデートといったメンテナンスがしやすくなります。

一方で、基本的には1つのバージョンだけを扱うことになるので、アップデート時には自動テストが重要になります。 newmoのフロントエンドでは、PlaywrightとFake Serverを使ったIntegration TestsやChromaticを使ったVRT(Visual Regression Test)など自動テストを充実させています。 ライブラリを扱うコードに対しては、Unit Testが書きにくいことも多いため、Integration Testsなどユーザーが見るものに対するテストに比重を置いています。 (主なロジックはGoで書かれたバックエンドにあるため、バックエンド側もE2Eテストなどを充実させてカバーしています)

副作用として、新しいパッケージを追加する際には、通常のバラバラのバージョンで管理するよりは心理的な障壁が高くなります。 良い面としてはちゃんと議論してからパッケージを追加することができるということですが、逆を言えばパッケージを簡単には追加できないということでもあります。 これは、newmoではちゃんと検討してからパッケージを追加するような意思決定をしたということなので、全ての開発でこの方法が適切というわけではありません。

One Version Ruleから複数バージョンへ移行するのは簡単ですが、その逆は難しいです。 このOne Version Ruleで破綻するところまでは、この方法でやってみようということで、newmoではフロントエンドを含め、Go言語のバックエンドやSwiftのiOSなども同様の方法でパッケージ管理を行っています。

PR: newmoではフロントエンドエンジニアを募集しています! 興味がある方は、次の採用情報をご覧ください。

Appendix: pnpm 9.5未満でのOne Version Ruleの実装

pnpm catalogはpnpm 9.5で追加された機能ですが、pnpm 9.5未満でも同様のOne Version Ruleを実装することができます。

pnpm.overridesworkspace:* を使うことで擬似的にpnpm catalogと同様の機能を実現することができます。 pnpm catalogが出る前にこの仕組みを実装して使っていて、pnpm catalogがリリースされたときには、pnpm catalogを使うように移行しました。

こちらの方式は pnpm overridesを使うのでrenovatebotなども対応しています。

pnpm catalogはまだリリースされたばかりなので、renomatebotやdependabotなどのツールはまだ対応していません。

pnpmfile.js (クリックで開く)

 /**
 * ## One Version Rule
 *
 * - Root package.json should define all versions of child packages via `pnpm.overrides`
 *  - e.g. `"pnpm": { "overrides": { "package-name": "1.0.0" } }`
 * - Each child package should use `workspaces:*` version instead of specific version
 *   - It uses the version defined in the root package.json's "pnpm.overrides"
 *   - e.g. `"dependencies": { "package-name": "workspace:*" }`
 *
 **/
const rootPkg = require("./package.json");
const fs = require("node:fs");
const path = require("node:path");
const rootDir = __dirname;
/**
 * Prefix for all monorepo packages
 * @type {string}
 */
const MONOREPO_PREFIX = "@newmo-app/";
const isMonorepoPackage = (pkgName) => {
  return pkgName.startsWith(MONOREPO_PREFIX);
};
/**
 * Pin the version of the package
 * @example
 * pinVersion("^1.0.0") // => "1.0.0"
 * pinVersion("~1.0.0") // => "1.0.0"
 * pinVersion("1.0.0") // => "1.0.0"
 * @param {string }version
 * @return {string}
 */
const pinVersion = (version) => {
  if (version.startsWith("^") || version.startsWith("~")) {
    return version.slice(1);
  }
  return version;
};
/**
 * Check if all packages are prefixed with {@link MONOREPO_PREFIX}
 */
const assertMonorepoPackageNameRule = (lockfile) => {
  // get all packages in the monorepo
  const pkgPathList = Object.keys(lockfile.importers).map((pathFromRoot) => {
    return path.join(path.resolve(rootDir, pathFromRoot), "package.json");
  });
  // check if all packages are prefixed with
  for (const pkgPath of pkgPathList) {
    const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
    if (!isMonorepoPackage(pkg.name)) {
      throw new Error(`Found invalid package name: ${pkg.name}
              
  Please make sure that all packages are prefixed with ${MONOREPO_PREFIX}
  `);
    }
  }
};
/**
 * Check if the package is not violating the One Version Rule
 * - All dependency versions should be pinned
 * - All `pnpm.overrides` versions should be pinned
 * @param pkg
 */
const assertRootPackage = (pkg) => {
  if (pkg.name !== rootPkg.name) {
    throw new Error(
      `Invalid argument: ${pkg.name}. This function only accepts root package.json`
    );
  }
  /**
   * @typedef {{depName:string; depVersion:string; depType: string; errorType: "invalid-semver-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  // all dependency versions should be pinned in root package.json
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // if depVersion is not pinned
        if (depVersion !== pinVersion(depVersion)) {
          errorPackageSet.add({
            depName,
            depVersion,
            depType,
            errorType: "invalid-semver-version",
          });
        }
      });
    }
  });
  // pnpm.overrides should be pinned
  if (pkg?.pnpm?.overrides) {
    Object.entries(pkg.pnpm.overrides).forEach(([depName, depVersion]) => {
      if (depVersion !== pinVersion(depVersion)) {
        errorPackageSet.add({
          depName,
          depVersion,
          depType: "pnpm.overrides",
          errorType: "invalid-semver-version",
        });
      }
    });
  }
  // throw error if there is any error
  if (errorPackageSet.size !== 0) {
    const commands = Array.from(errorPackageSet)
      .map((errorPackage) => {
        if (errorPackage.errorType === "invalid-semver-version") {
          if (errorPackage.depType === "pnpm.overrides") {
            return `npm pkg set 'pnpm.overrides.${
              errorPackage.depName
            }'='${pinVersion(errorPackage.depVersion)}'`;
          }
          return `npm pkg set '${errorPackage.depType}.${
            errorPackage.depName
          }'='${pinVersion(errorPackage.depVersion)}'`;
        }
      })
      .join("\n");
    throw new Error(`Found invalid dependency versions in root package.json
  
  This monorepo enforces all dependencies to be pinned version in root "package.json".
  Please run the following commands to fix the issue:
  
  \`\`\`
  cd ${rootDir}
  ${commands}
  pnpm install
  \`\`\`
    `);
  }
};

/**
 * Check if the package is not violating the One Version Rule
 * @param pkg
 */
const assertWorkspacePackage = (pkg) => {
  /**
   * @typedef {{depName:string; depVersion:string; depType:string; packageName:string; errorType: "non-overrides" | "invalid-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // Skip monorepo packages
        // e.g. "@monorepo/package-name" -> "@monorepo/shared-lib"
        if (isMonorepoPackage(depName)) {
          return;
        }
        // check if the dependency is defined in root package.json
        if (!rootPkg.pnpm.overrides[depName]) {
          errorPackageSet.add({
            depName,
            depVersion,
            depType,
            packageName: pkg.name,
            errorType: "non-overrides",
          });
        }
        // check if the dependency version is defined as workspace:*
        if (!depVersion.startsWith("workspace:")) {
          errorPackageSet.add({
            depName,
            depVersion,
            depType,
            packageName: pkg.name,
            errorType: "invalid-version",
          });
        }
      });
    }
  });

  if (errorPackageSet.size !== 0) {
    /**
     * @param {RuleError}
     */
    const fixCommand = ({
      depName,
      depVersion,
      depType,
      packageName,
      errorType,
    }) => {
      if (errorType === "non-overrides") {
        // If already defined as workspace:*, then get latest version from npm
        if (depVersion.startsWith("workspace:")) {
          return `npm pkg set 'pnpm.overrides.${depName}'="$(npm info ${depName} version)"`;
        }
        return `npm pkg set 'pnpm.overrides.${depName}'='${pinVersion(
          depVersion
        )}'`;
      }
      if (errorType === "invalid-version") {
        return `npm --prefix=$(find . -name package.json | xargs grep -l '"name": "${packageName}"' | xargs dirname) pkg set '${depType}.${depName}'='workspace:*'`;
      }
    };
    const commands = Array.from(errorPackageSet)
      .map((errorPackage) => {
        return fixCommand(errorPackage);
      })
      .join("\n");
    throw new Error(`Found packages that violate the rule
  
  This monorepo enforces all dependencies to be defined in root "package.json" and force the version via "pnpm.overrides".
  Please run the following commands to fix the issue:
  
  \`\`\`
  cd ${rootDir}
  ${commands}
  pnpm install
  \`\`\`
  
  詳細は **社内Notionへのリンク** を参照してください。
  
  `);
  }
};

function afterAllResolved(lockfile, context) {
  assertMonorepoPackageNameRule(lockfile);
  return lockfile;
}

/**
 * Read package.json and check if it violates the Mono Version Rule
 * @param pkg
 * @param context
 * @returns {*}
 */
function readPackage(pkg, context) {
  // pnpm create does not have package name
  const isPnpmCreate = pkg.name === undefined;
  if (isPnpmCreate) {
    return pkg;
  }
  // SKip outside monorepo packages
  if (!isMonorepoPackage(pkg.name)) {
    return pkg;
  }
  // Check root package
  if (pkg.name === rootPkg.name) {
    assertRootPackage(pkg);
    return pkg;
  }
  // Check workspace package
  assertWorkspacePackage(pkg);
  return pkg;
}

module.exports = {
  hooks: {
    readPackage,
    afterAllResolved,
  },
};

Reviewed by ito.

iOSDC Japan 2024 にて「GraphQLとスキーマファーストで切り開くライドシェアの未来」について話しました! #iosdc

iOSDC Japan 2024 にスポンサーセッションで登壇しました

こんにちは。newmoのソフトウェアエンジニアの@kuです。

先週開催されたiOSDC Japan 2024にて、Day2の夕方に「GraphQLとスキーマファーストで切り開くライドシェアの未来」というタイトルで登壇させていただきました。(トーク情報

このブログでは、当セッションの登壇資料、補足・裏話をシェアします。

 

登壇資料

speakerdeck.com

 

本セッションでは、GraphQLのディレクティブを使ってスキーマにより多くの情報を持たせ、そこからコードを生成することで、異なるソフトウェア間で一貫性のある実装を安全に、高速に行う弊社の取り組みを紹介しました。

GraphQLのディレクティブはあまり紹介されないものの、様々な用途に利用できるので、iOS開発でもより広く使われるようになるといいなと思っています。

(セッションの動画は、後日iOSDC Japan公式YouTube Channelで公開されるはず。もう少々お待ち下さい。)

 

おわりに

iOSDCでは2021以来GraphQLのセッションはなかったので、もうみんなGraphQLに興味がないのでは?と心配だったのですが、満員御礼となって嬉しかったです。ありがとうございました。

 

また、1階のnewmoブースには3日間で約200名の方に来ていただきました。newmoのことを知っている人も、知らない人も居ました。とにかく、たくさんのエンジニアと交流できてとても楽しかったです。

 

セッションやブースで話を聞いて、newmoがどんな会社かちょっと気になる…そう思った方は、newmoのキャリアページnewmoの求人一覧をご覧ください。

 

youtrust.jp

 自分とのカジュアル面談もぜひ。

 

newmoは来月開催予定のDroidKaigi 2024にもブース出展するので、DroidKaigiに参加予定の方が居たら、来月の会場でお話しましょう!

newmo は「エンジニアの楽園 vim-jp ラジオ」を応援しています! #vimjpradio

こんにちは。newmo の TechPR 担当です。

newmo は、2024年にスタートした「エンジニアの楽園 vim-jp ラジオ」を応援しています。
newmo の vimmer も他のエディタ使いも、いつも楽しく vim-jp ラジオを聞かせていただいており、協賛できることを嬉しく思います。

協賛にあたり、以下日時のお知らせコーナーにて、newmo の情報が配信される予定です。

  • 9月9日  #10 @yusukebe さん回
  • 9月16日   #11 @uzulla さん回

vimmer の方も、そうでない方も。
vim-jp ラジオの audee ページと vim-jp X アカウント(@vimjpradio)を要チェックです!

audee.jp

 

newmo は iOSDC Japan 2024 にゴールドスポンサーとして協賛します!ブース出展 & Day2にセッションも(記事内チャレンジトークン有り) #iosdc

こんにちは。newmo の TechPR 担当です。

newmo は、2024年8月22日〜24日に開催予定の iOSDC Japan 2024 にゴールドスポンサーとして協賛します!
創業1年目の会社ですが、iOSコミュニティの発展に寄与できることを嬉しく思います。

記事内にiOSDCチャレンジトークンがあります。
※ iOSDCチャレンジトークンとは、公式が催している全員参加型企画「iOSDCチャレンジ」に使用するトークンのことです。詳しくはこちらの公式案内ブログをご覧ください。

iOSDC Japan 2024 開催概要

newmo セッション情報

Day2の昼過ぎに、newmo エンジニア @ku による以下セッションを実施予定です。

より安全に、高速にiOS開発を進めていくための弊社の取組みをご紹介します。ご興味ある方は、ぜひご聴講・ご視聴ください。

iOSDCチャレンジトークン

このセッションを聞いて、

#ライドシェアの未来

について、一緒に考えてみませんか?

newmo ブース情報

前夜祭からDay2まで、オフライン会場にてブースを出展します。

ブースお品書き

newmoのビジネス紹介ポスター

創業して半年。まだiOSアプリがリリースされていないので、newmoのビジネスについてご存じない方も多いと思います。そんな方のために、創業の背景・解決したい課題・newmoの特徴などをまとめたポスターを掲示 & newmo ブース担当者が口頭で説明します。

newmo iOS Design Blueprint ポスター

以下の情報をポスターにして掲示 & ブース担当者が口頭で説明します!

  • Multiple app architecture
  • the Composable Architecture
  • Usage of Google Fleet Engine
  • Interaction with GraphQL

ノベルティ

ロゴステッカー

まだ持っている人が少ない、レアなステッカー?!

フェイスタオル

汗をかく季節。タオルは何枚あっても嬉しいですよね。

塩分チャージタブレット

この季節は熱中症に注意!汗で流れた塩分を補給しましょう。

ブラックサンダー

会場内を動き回ったりセッションを聞いて頭を使ったら、脳と身体が糖分を欲しているはず。小休憩とともにどうぞ。

イベント直前・タイムテーブルチェック会

newmo-tech.connpass.com

開催3日前の 8/19(月)に、「iOSDC Japan 2024 のタイムテーブルをチェックする会」を開催します。「既にタイムテーブルはチェック済みだよ」という方も、そうでない方も。
登壇者の @noppe さんや @ooba さん、過去参加経験のある @d_date さんや @hcrane14 さんと一緒に、わいわいしましょう。
お昼の時間帯にオンライン配信するので、ご飯を食べながらの視聴もウェルカムです!

▼申込みはこちら
https://newmo-tech.connpass.com/event/327148/

最後に

現地の様子はX(旧Twitter)の @newmotech でもポスト予定です。ぜひこちらのアカウントもフォローしておいてください。

newmoがどんな会社かちょっと気になる…そんな方は、ぜひ事前にnewmoのキャリアページをご一読ください。 careers.newmo.me

ただ今 iOSエンジニアも募集中です。newmoの求人一覧はこちらhrmos.co

それでは、当日お会いできるのを楽しみにしています!

Go Conference 2024 にシルバースポンサーとして参加しました!

プロダクト開発に採用しているプログラミング言語Goのカンファレンス「Go Conference 2024」において、newmoはSilverスポンサーとして協賛いたしました。

当日は、CTOの @sowawa 、アーキテクトの yuki.ito、そして 筆者の@yui_tang が参加しました。 5年ぶりにオフライン開催となったGo Conferenceですが、多くの参加者が朝早くから集まり、1日を通して盛り上がりを見せていました!

創業間もないnewmoですが、幸運なことにブース出展の機会を得ることができました。 多くの方々に、バックエンドアーキテクチャや事業について興味を持っていただく貴重な機会となりました。ブースにお越しいただいた皆様、ありがとうございました!

newmoブース

yuki.itoによるライブコーディングでは、アーキテクチャの説明やデバッガーを使って高速に開発する実演を行い、多くの方の目に留まりお話しさせていただきました。 より詳細な内容は、7月にシカゴで行われるGopherConと、その後開催予定のrecapイベントにて発表予定ですので、お楽しみに!

ブースでは、ロゴステッカーの他に、オリジナルブラックサンダーとGoのデバッガーであるDelveのチートシートになるクリアファイルを配布いたしました。

newmoノベルティ
Delveチートシートになるクリアファイル
ブースにてクリアファイルを持って記念撮影する来場者(掲載許可を頂いています
若干数残っておりますので、ご興味のある方はぜひnewmo Beer Bashにお越しください🍻

Go Conference 2024にSilverスポンサーとして協賛できたことは、newmoとして技術コミュニティに初めて接する機会でもあり、心より感謝しております。 今後もnewmoは、Goをはじめとするテックコミュニティへの貢献を続けてまいります。


newmo ではエンジニアを積極的に採用中です!

興味があれば DM やカジュアル面談でもお気軽に連絡ください〜

newmo株式会社はGo Conference 2024にてスポンサーブースを出します!

こんにちは、newmo 株式会社に所属しているソフトウェアエンジニアのyui_tangです。

newmoは、Go Conference 2024にSilverスポンサーとして協賛します!

2024年1月に創業したばかりの弊社が、初めて技術カンファレンスへ協賛出来ることを大変嬉しく思います。

gocon.jp

newmoからは、CTOのsowawa・アーキテクトの伊藤とわたしの3名が参加を予定しています。

出来たばかりのnewmo Tシャツを着てブースでお待ちしておりますので、ぜひお越しください。

newmoロゴ入りTシャツ
newmo Tシャツ

ブースにて、ライブコーディングや事業・プロダクトの設計についての説明等を行います。

更に、ブースへ来てくれた方々には、便利なチートシート付き特製クリアファイルオリジナルのブラックサンダーをお配りします!

会場で、皆様とお会いできるのを楽しみにしています。


newmo ではエンジニアを積極的に採用中です!

興味があれば DM やカジュアル面談でもお気軽に連絡ください〜

Go + GraphQL による Modular Monolith なシステム設計について発表しました

こんにちは、newmo 株式会社に所属しているアーキテクトの伊藤です。

5/22 に【Go】カンファレンススポンサーブースの集い 2024 〜カウシェ × newmo × メルカリ〜というイベントを開催しました。 このイベントで、筆者からは GraphQL Federation や Go のための GraphQL のコード生成、Modular Monolith によるシステム設計について話しました。

スライドや動画のアーカイブは、次の場所で公開されているので、ぜひご覧ください!

筆者が発表した内容を簡単にまとめると、次の3つのポイントが挙げられます。

  • GraphQL Federation とその採用理由
  • GraphQL のスキーマから Go のコードを生成している仕組み
  • Modular Monolith なシステム設計

GraphQL を採用した理由

newmo ではクライアント(ウェブやモバイルアプリ)からサーバの API を呼び出す際に GraphQL を採用しています。 発表でも触れましたが、GraphQL を採用した理由としては、エコシステムの豊富さや GraphQL Federation の存在が挙げられます。

GraphQL Federation

GraphQL Federation は、複数の GraphQL のグラフを一つにまとめるような仕組みで、具体的な実装としては Apollo Federation を利用しています。複数のシステムの API をまとめる Aggregation レイヤーを自動的に生成して、CI/CD で常に合体したグラフが生成されるので、Aggregation レイヤーのコンポーネントを管理する手間がなくなります。

GraphQL のスキーマから Go のコードを生成している仕組み

GraphQL のスキーマは宣言的なファイルとして管理しています。 newmo では GraphQL の Operation(クライアントが実際に利用する Query や Mutation の定義)も宣言的なファイルとして管理するようにしています。

GraphQL のスキーマからCustom Validationを生成

これによって、GraphQL のスキーマや Operation から Go や TypeScript などのコードを生成することができます。 具体的なコード生成ツールとしては gqlgenGraphQL-Codegen などを利用しています。

これらのコード生成ツールはプラグインとして生成するコードをカスタマイズできるため、newmo ではスキーマに @validateString のような独自の Directive を書いて、スキーマからバリデーションコードを生成しています。

発表では時間の問題で触れることができませんでしたが、スキーマから Fake Server を生成して開発に利用したり、 GraphQL のエラーガイドラインを作成して、対処が画一的なエラーに関してはエラーハンドリングを自動化することも検討しています。

この辺りについては、今後ブログなどで発表していく予定です!

Modular Monolith なシステム設計

Modular Monolith

newmo では、最初からMicroservice でシステムを構築するのではなく、Modular Monolith 形式で開発を進めていこうとしています。

Component という形式でモジュールを定義して、単一のプロセス(コンテナ)で動作するようにし、Component 同士は gRPC 経由でやりとりするような構造にしようとしています。 これによって、少ない工数で初期のシステムを構築しつつ、後から Component をプロセスとしても分離しやすくするといった狙いがあります。

詳細については、スライドや動画も公開しているので、是非ご覧ください〜


このイベントでは、他にも株式会社メルペイの goccy さんと株式会社カウシェの uo さんが gRPC について話されています。 イベントの動画アーカイブも公開されているので、是非ご覧ください!

newmo ではエンジニアを積極的に採用中です!

興味があれば DM やカジュアル面談でもお気軽に連絡ください〜