Skip to content
All articles

My PLAID Internship: Improving Developer Experience in a Large Monorepo

A detailed account of my summer internship on PLAID's Developer Experience & Performance team, including faster CI, internal ESLint plugins, a pnpm catalog migration, and the working environment.

PLAID, Inc.
Cover for an internship report about improving Developer Experience in PLAID's large monorepo
Image: PLAID Engineer Blog

Hello, I am Reo Hakuta (GitHub @ReoHakase), a third-year Information Science student at Kyoto Institute of Technology, graduating in 2027.
From August 16 to September 15, 2025, I joined PLAID's Developer Experience & Performance team for a one-month summer internship. This article covers what I delivered, how I worked, and what I learned.

Full-length photograph of Reo Hakuta in PLAID's office
At PLAID's office

👤 About me

I usually build full-stack TypeScript applications and often work on frontend and backend systems in Turborepo monorepos. I particularly enjoy frontend work, from creating designs in Figma to optimizing applications with React Server Components and TanStack Query.

One example is reaching the DCON2025 final organized by JDLA and NHK. Our team placed fifth out of 95 teams and received the Topy Industries Award, Hitachi Industry & Control Solutions Award, and Quick Award.

  • Locker.ai: an automated lost-property service combining an LLM with smart lockers
  • Full-stack TypeScript development with Next.js, NestJS, and GraphQL
DCON2025 final archive

I graduated from the National Institute of Technology, Ibaraki College and transferred into the third year at Kyoto Institute of Technology. PLAID was the second company where I had taken part in a paid internship.

📜 Why I joined

I was already interested in Developer Experience. In my own projects, I had used GitHub Actions, Vitest, Storybook with an accessibility plugin, and Playwright for CI, and had used Turborepo caching to make CI more efficient. At the same time, I felt that a personal project could not provide meaningful experience with a genuinely large codebase. I discussed this with PLAID representatives at a Supporterz one-on-one event at the end of March, which led to the summer internship.

PLAID's KARTE codebase, in the karte-io-systems repository, was also a strong match for my interests: it was a very large monorepo implemented mainly in TypeScript.

The repository contained multiple pnpm workspaces under systems/*, each holding several packages. It had more than 296,000 commits, and Issue and pull request numbers had reached approximately 133,000.

karte-io-systems/
. (karte-io-systems)
├── .git
├── package-lock.json
├── package.json
├── README.md
└── systems
    ├── academy2
    ├── action-editor
    ├── action-table
    ├── admin
    ├── apiv2
    └── ... (36 systems in total)

✨ What I delivered

The Developer Experience & Performance team improves the codebase across product boundaries. Consequently, the scope and size of my tasks varied considerably.

  • karte-io-systems
    • Added @plaidev/eslint-plugin-express (#133382)
    • Added Namespace profile names to the actionlint configuration (#132929)
    • Moved the karte-ai-test execution environment from GitHub Actions to Namespace to improve cache efficiency (#132849)
    • Removed aliases from the KARTE AI Vitest configuration (#132603)
    • Consolidated dependencies into pnpm catalogs with pncat (#132587)
    • Extracted path conversion in karte-ai-test CI into a separate step (#132545)
    • Enabled the recommended and stylistic typescript-eslint rules in old-karte/front-react (#132117)
    • Configured Vitest Test Projects and changed-file testing for KARTE AI (#132071)
    • Migrated old-karte/front-react to ESLint v9 (#131871)
    • Added appropriate ARIA roles and keyboard interaction to EventItem (#131736)
    • Replaced a div and label with fieldset and legend in SegmentConditionBuilder (#131732)
    • Renamed the MessageBubble prop role to type to avoid conflicts with accessibility rules (#131728)
    • Prevented ESLint and type-check processes from terminating abnormally and enabled both checks in CI for old-karte/front-react (#131656)
    • Fixed CI so package-level lint and lint:i18n tasks ran in old-karte (#13145)
  • eslint-plugin
    • Created @plaidev/eslint-plugin-express, @plaidev/eslint-plugin-hono, and @plaidev/eslint-plugin-trpc (#4)
    • Created a Turborepo monorepo environment (#2)
  • sour-mcp-server
    • Added support for the latest Tailwind CSS v4-compatible sour-tailwind (#6)
  • karte-io-terraform
    • Configured Trivy locally and in GitHub Actions for terraform/aws/evaluation and gcp/evaluation-karte-io-systems (#1448)

The following five projects were the most instructive.

Background

The KARTE AI pnpm workspace is used to validate and develop AI features in the KARTE product family. Its Mastra server tests include LLM behavior checks, so every CI run could take a long time. I set out to run only the tests related to changed files.

systems/karte-ai/
karte-io-systems/systems/karte-ai/
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
├── apps/
│   ├── backend/           # Mastra server
│   ├── front/             # React administration UI
│   ├── web/               # Backend API server
│   └── *-mcp/             # MCP servers
└── packages/
    ├── shared/            # Shared code
    ├── shared-server/     # Shared server code
    └── trpc/              # API schema

Previously, Turborepo invoked Vitest independently in each package. There were twelve separate vitest.config.ts files.

Two Vitest features were relevant:

  • vitest related <file> runs tests for a specified file and the files that depend on it.
  • vitest run --changed=<ref> finds every file changed from a branch or commit and applies the same related-test logic.

In a monorepo, Vitest also has to follow dependencies across internal packages declared with workspace:. I first configured Vitest Test Projects, consolidating the package configurations at the workspace root and inlining internal @karte-ai/* packages.

systems/karte-ai/vitest.config.ts
const alias = {
  '@karte-ai/shared': path.resolve(__dirname, './packages/shared/src/index.ts'),
  '@karte-ai/shared-server': path.resolve(__dirname, './packages/shared-server/src/index.ts'),
  '@karte-ai/trpc': path.resolve(__dirname, './packages/trpc/src/index.ts'),
};

export default defineConfig({
  test: {
    projects: [
      {
        extends: true,
        test: {
          name: 'backend',
          include: ['apps/backend/**/*.test.{ts,js}'],
          includeSource: ['apps/backend/src/**/*.{js,ts,jsx,tsx}'],
          globals: true,
          environment: 'node',
        },
      },
      // The other eleven projects follow the same structure.
    ],
    alias,
    server: { deps: { inline: [/@karte-ai\\//] } },
  },
});

This allowed Vitest to track file-level dependencies across package boundaries. vitest run --changed=develop could then identify the tests affected by a pull request. The final implementation used Node.js Type Stripping and pointed exports.default to the pre-build .ts entry points, avoiding the aliases shown above.

There was another constraint: vitest run --changed=<ref> relies on git diff --name-only. CI therefore needs enough history to find the merge base between develop and the pull request branch; fetch-depth: 1 is insufficient.

I experimented with sparse checkout and --shallow-exclude, but checkout still took more than 50 seconds in a repository with over 296,000 commits. Instead of fetching additional Git history, I used the GitHub REST API through tj-actions/changed-files@v46 with use_rest_api: true. CI converted the returned repository paths to paths relative to systems/karte-ai and passed them to vitest related.

.github/workflows/karte-ai-test.yml
- name: Get changed files
  if: ${{ github.event_name == 'pull_request' }}
  id: calc_changed
  uses: tj-actions/changed-files@v46
  with:
    token: ${{ secrets.GITHUB_TOKEN }}
    use_rest_api: true
    files: |
      systems/karte-ai/**

- name: Test related files
  if: ${{ github.event_name == 'pull_request' }}
  run: pnpm exec vitest related ${{ steps.convert_paths.outputs.files }} --run

- name: Test all files
  if: ${{ github.event_name != 'pull_request' }}
  run: pnpm test:all

Retrieving the changed-file list took about two seconds. Pull request events ran only related tests, while pushes to develop continued to run the full suite as a safety measure.

Result from vitest related
Test Files  3 passed | 1 skipped (4)
     Tests  7 passed | 3 skipped (10)
  Duration  37.65s

2. Developing internal ESLint v9 packages

I implemented internal ESLint plugins for Express, Hono, and tRPC and published them as internal packages. To make future plugins easier to maintain, the project used:

  • A Turborepo monorepo that can accommodate multiple internal plugins
  • GitHub Actions, Changesets, and pnpm for releases and publishing
  • Vitest and eslint-vitest-rule-tester for valid and invalid test cases
  • Biome, Lefthook, and tsdown

One rule, eslint-plugin-hono/no-project-id-from-context, forbids using a project ID read from Hono's c.req.json(). It instead directs developers to safer sources:

  • c.get('currentProject')._id, derived from the authenticated user's current project
  • A server-managed environment variable
  • A database lookup based on authenticated information

I implemented equivalent user_id and api_key rules, corresponding Express and tRPC plugins, and their tests. The eslint-vitest-rule-tester helpers let the test cases closely mirror the requirements.

no-project-id-from-context.test.ts
describe("no-project-id-from-context", () => {
  const { valid, invalid } = createRuleTester({
    name: "no-project-id-from-context" satisfies RuleName,
    rule: noProjectIdFromContext,
    languageOptions,
  })

  it("allows the authenticated current project ID", async () => {
    const { result } = await valid({
      code: `const currentProject = c.get('currentProject');
        await handler({ projectId: currentProject._id });`,
    })
    expect(result).toMatchSnapshot()
  })

  it("rejects a project ID from the request body", async () => {
    const { result } = await invalid({
      code: `const body = await c.req.json();
        await handler({ projectId: body.project_id });`,
      errors: [{ messageId: "fromContext" }],
    })
    expect(result).toMatchSnapshot()
  })
})

Snapshots also covered the reported location and error message. After publishing, the plugins were introduced into several workspaces.

Terminal showing an internal ESLint plugin detecting a project ID derived from a request body
Detection by the internal ESLint plugin

3. Automating a pnpm catalog migration with pncat and contributing upstream

I used the pncat CLI to automate a migration to pnpm catalog, centralizing dependency versions in a monorepo. The migration covered two pnpm workspaces and changed 47 files in total.

Background

Previously, each package.json stored its own versions. Different versions of the same package could coexist and install duplicate dependencies, and upgrades required edits in several places. A pnpm catalog offered a central source of truth for shared versions.

Approach

I installed pncat and configured all packages to use the unnamed default catalog.

Terminal
pnpm i -Dw pncat

pnpm supports an unnamed catalog (catalog: or catalog:default) and named catalogs (catalog:<name>). Named catalogs are useful when a monorepo must retain multiple major versions. Categorizing packages by labels such as test, form, or validator, however, would allow the same dependency to be specified more than once without providing a clear benefit. I therefore overrode pncat's category-oriented default rules.

pncat.config.ts
import { defineConfig } from "pncat"

export default defineConfig({
  catalogRules: [{ name: "default", match: [/.+/] }],
})

The migration commands were:

Terminal
# Expand catalog references back into explicit versions when necessary.
pnpm pncat revert

# Migrate explicit versions into the configured catalog.
pnpm pncat migrate

When pncat detected several versions, it prompted for the version to retain. After migration, pnpm-workspace.yaml held the central catalog. I enabled catalogMode: strict to require catalog use and cleanupUnusedCatalogs: true to remove unused entries automatically.

pnpm-workspace.yaml
catalog:
  "@ai-sdk/react": 1.2.12
  "@ark-ui/react": 5.15.1
  "@fortawesome/react-fontawesome": 0.2.2

catalogMode: strict
cleanupUnusedCatalogs: true

Each package.json then referred to the catalog instead of repeating a version:

package.json
{
  "dependencies": {
    "@ai-sdk/react": "catalog:",
    "@ark-ui/react": "catalog:",
    "@fortawesome/react-fontawesome": "catalog:"
  }
}

The two systems moved 127 and 90 packages respectively under catalog management. Centralizing versions and removing duplicates reduced the work required for dependency updates.

In pnpm v10.15.1, pnpm remove did not remove an entry from the catalog until the next pnpm install; a fix was planned for v10.15.2 in pnpm/pnpm#9930.

I also contributed two fixes to pncat after noticing that it emitted the redundant catalog:default instead of catalog::

4. Evaluating a move from GitHub Actions to Namespace for better caching

I moved the karte-ai-test execution environment from GitHub Actions to Namespace and evaluated its caching. Test execution fell from 1 minute 14 seconds to 32 seconds, a 2.3-times speedup.

Container initialization and pnpm install had dominated the original job:

  • With a cache hit, pnpm install took about 17 seconds.
  • With a cache miss, pnpm install took about 52 seconds, followed by about 45 seconds to upload the cache with actions/cache.

When dependencies changed frequently, a cache this slow offered little benefit. Repository-level capacity limits also prevented the GitHub Actions cache from being used effectively.

Namespace advertises runners with built-in Docker image caching and high-throughput local NVMe caching, avoiding cache upload and download bandwidth. Its Cache Volumes are designed to persist data between GitHub Actions runs with cache locality. I changed the runner with one line and added Namespace's persistent cache actions for pnpm and Turborepo.

.github/workflows/karte-ai-test.yml
jobs:
  karte-ai-test:
    runs-on: namespace-profile-ubuntu2404-amd64-16core

steps:
  - name: Set up pnpm cache
    uses: namespacelabs/nscloud-cache-action@v1
    with:
      cache: pnpm

  - name: Set up Namespace Turborepo cache
    uses: namespace-actions/setup-turbocache@v0

This reduced the job to 32 seconds.

GitHub Actions run showing the karte-ai-test job completing in 32 seconds on Namespace
CI job running on Namespace

Namespace also provides namespacelabs/nscloud-checkout-action, but it did not support sparse checkout. Even with fetch-depth: 1 and caching, checkout took about 15 seconds in karte-io-systems, compared with about two seconds for actions/checkout with sparse checkout. I therefore kept the latter.

5. Trialing Terraform static analysis with Trivy

For Terraform in the evaluation environments aws/evaluation and gcp/evaluation-karte-io-systems, I compared Trivy and Checkov as candidates for trial static analysis.

A usable VS Code extension was important to the Developer Experience. The Prisma Cloud extension for Checkov required a Prisma Cloud API key and, according to the source I found, access was paid. A community-maintained open-source Checkov extension also existed, but I could not get it to work, so I selected Trivy.

Error dialog showing a failed Checkov scan because the Checkov command was not found on macOS
Error from the Checkov VS Code extension

I then configured trivy.yaml, the VS Code extension, and GitHub Actions.

Trivy's VS Code extension underlining a Terraform configuration issue
Detection by the Trivy VS Code extension
GitHub Actions workflow running Trivy static analysis for Terraform
Trivy static analysis in GitHub Actions

💼 Working environment

PLAID combines office and remote work with flexible hours. I worked at the GINZA SIX office during the first week, then mostly worked remotely from my home in Kyoto for an average of about 40 hours per week. The flexible schedule made it easier to accommodate university commitments during the day.

🚅 Travel and accommodation

For that year's summer internship, PLAID reimbursed up to ¥25,000 per month for travel and up to ¥50,000 for accommodation when working from the office. That was sufficient for my first week in Tokyo. These terms may change for future internships.

💻 Development environment

PLAID provided:

  • A 16-inch M2 Max MacBook Pro with 96 GB of memory
  • A Cursor Team Plan account
  • A Claude Code account through Google Vertex AI
  • An OrbStack Pro account

This let me work with an environment close to my usual setup.

Terminal displaying system information for the 16-inch M2 Max MacBook Pro provided during the internship
The provided 16-inch M2 Max MacBook Pro

🏢 The office

PLAID's office occupies the tenth floor of GINZA SIX, directly connected to Ginza Station. It included a large coffee machine, water dispensers, meeting booths, and an event area with artificial grass. Desks were unassigned, so I often sat beside my on-the-job trainer and worked while asking questions and chatting. Most monitors were 4K or 5K. Ice, dairy milk, soy milk, and almond milk were available, and I often made a café latte while working.

Coffee machine and drink facilities in PLAID's office
Office coffee machine
Artificial-grass event area and workspace in PLAID's GINZA SIX office
PLAID's office on the tenth floor of GINZA SIX

🍽️ Welcome Lunch program

When a meal included a new employee or intern, the Welcome Lunch program provided up to ¥2,000 per person. It gave me opportunities to meet employees and other interns at restaurants around Ginza that I would not normally visit as a student.

Grilled meat and salad served at a Welcome Lunch
Welcome Lunch: grilled meat
A hot pot with meat and vegetables shared at a Welcome Lunch
Welcome Lunch: shabu-shabu
A set meal with tonkatsu, rice, and miso soup served at a Welcome Lunch
Welcome Lunch: tonkatsu set meal

💭 Reflections

My previous internship at another company had been fully remote, so this was my first internship that included time in an office. Working in person gave me more opportunities to interact with team members, eat together, and join the company-wide monthly “shimemeshi” gathering. It made the month particularly meaningful and enjoyable. Thank you to everyone at PLAID for the experience.

Next article

Five weeks at Victoria University — Classes and life in Melbourne