Back to Blog

    Scaffolding a Monorepo with StructKit

    August 20, 2026Kenny Belitzky
    Scaffolding a Monorepo with StructKit

    Monorepos solve the cross-repository consistency problem by removing the cross-repository part. One checkout, one dependency graph, one CI pipeline, atomic changes across packages. For a lot of teams that is the right call.

    Then you add the eleventh package, and discover the problem moved rather than disappeared.

    Every package needs the same scaffolding — a manifest, a test directory, a linter config, a build target, an entry in the workspace file. The tooling that used to differ between repositories now differs between directories in the same repository. And because it is all one checkout, the copying is even easier: duplicate packages/billing, rename things, start. The golden-repo failure mode, at a shorter distance.

    This walks through defining the package shape once and generating the workspace from it.

    The shape of the problem

    A typical monorepo has three tiers of files, and conflating them is what makes the whole thing unwieldy.

    Root-level, exactly one copy. Workspace config, root tsconfig.json or pyproject.toml, CI pipeline, CODEOWNERS, the lockfile.

    Per-package, identical. Linter config, test setup, build script, the manifest's boilerplate half. These are the ones that get copied.

    Per-package, genuinely different. Source code, dependencies, the package's own README.

    A definition should generate the first two and stay out of the way of the third.

    Root and packages in one definition

    folders: composes structures into subdirectories, with with: passing variables down. That is the whole mechanism:

    description: >
      Acme platform monorepo: root workspace config plus per-package scaffolds.
    
    files:
      - package.json:
          content: |
            {
              "name": "@acme/platform",
              "private": true,
              "workspaces": ["packages/*", "services/*"],
              "scripts": {
                "build": "turbo run build",
                "test": "turbo run test",
                "lint": "turbo run lint"
              }
            }
      - turbo.json:
          file: github://acme/platform-templates/main/monorepo/turbo.json
      - .github/CODEOWNERS:
          content: |
            /packages/billing/    @acme/payments
            /packages/shipping/   @acme/fulfilment
            /services/api/        @acme/platform
      - .github/workflows/ci.yml:
          file: github://acme/platform-templates/main/workflows/monorepo-ci.yml
      - README.md:
          skip_if_exists: true
          content: |
            # Acme Platform
    
    folders:
      - ./:
          struct:
            - configs/editor-config
            - configs/prettier
            - github/templates
    
      - packages/billing:
          struct: acme/node/library
          with:
            package_name: billing
            package_description: "Invoice generation and payment reconciliation"
            owning_team: payments
    
      - packages/shipping:
          struct: acme/node/library
          with:
            package_name: shipping
            package_description: "Carrier rates and label generation"
            owning_team: fulfilment
    
      - services/api:
          struct: acme/node/service
          with:
            package_name: api
            package_description: "Public GraphQL gateway"
            owning_team: platform
    structkit generate .struct.yaml .

    One command produces the root files and every package. acme/node/library is defined once and rendered three times with different variables.

    Note that root-level shared configs come through folders: - ./: composing configs/editor-config and configs/prettier — the same composition mechanism, targeting the repository root rather than a subdirectory.

    The package structure

    acme/node/library is an ordinary definition. What makes it work in a monorepo is that everything package-specific comes from a variable:

    description: >
      Standard Acme Node library package for the platform monorepo.
    
    variables:
      - package_name:
          description: "Package directory name, e.g. billing"
          type: string
      - package_description:
          description: "One sentence describing the package"
          type: string
      - owning_team:
          description: "GitHub team that owns this package"
          type: string
    
    files:
      - package.json:
          content: |
            {
              "name": "@acme/{{@ package_name | slugify @}}",
              "version": "0.1.0",
              "description": "{{@ package_description @}}",
              "main": "dist/index.js",
              "types": "dist/index.d.ts",
              "scripts": {
                "build": "tsc -b",
                "test": "vitest run",
                "lint": "eslint src/"
              }
            }
    
      - tsconfig.json:
          content: |
            {
              "extends": "../../tsconfig.base.json",
              "compilerOptions": {
                "outDir": "dist",
                "rootDir": "src"
              },
              "include": ["src/**/*"]
            }
    
      - src/index.ts:
          skip_if_exists: true
          content: |
            export const name = "{{@ package_name @}}";
    
      - src/index.test.ts:
          skip_if_exists: true
          content: |
            import { expect, test } from "vitest";
            import { name } from "./index";
    
            test("exports a name", () => {
              expect(name).toBe("{{@ package_name @}}");
            });
    
      - eslint.config.js:
          file: github://acme/platform-templates/main/monorepo/eslint.package.config.js
    
      - README.md:
          skip_if_exists: true
          content: |
            # @acme/{{@ package_name @}}
    
            {{@ package_description @}}
    
            **Owner:** @acme/{{@ owning_team @}}

    Three decisions in there carry most of the value.

    tsconfig.json has no skip_if_exists. The build wiring is the template's, and regeneration should correct it. This is the file that drifts silently in hand-maintained monorepos — one package with a slightly different outDir and a build that behaves oddly for reasons nobody traces.

    src/index.ts and the test do have it. They are seeds. Once a package has real code, regeneration must not touch it.

    eslint.config.js is a remote reference. One canonical copy for the whole organization, not one per package that slowly diverges.

    Adding a package to a monorepo that already exists

    This is the operation that happens weekly, and the one worth optimizing.

    A small definition for just the new package:

    sources:
      acme:
        url: github://acme/platform-structures@v2.1.0/structures
    
    folders:
      - packages/{{@ package_name @}}:
          struct: acme/node/library
          with:
            package_name: "{{@ package_name @}}"
            package_description: "{{@ package_description @}}"
            owning_team: "{{@ owning_team @}}"
    
    variables:
      - package_name:
          description: "New package name"
          type: string
      - package_description:
          description: "One sentence"
          type: string
      - owning_team:
          description: "Owning GitHub team"
          type: string
    structkit generate -d --diff new-package.yaml .
    structkit generate new-package.yaml .

    Always the dry run first. In a monorepo the target directory is never empty, and --diff is what tells you whether the definition is about to touch something it should not.

    The file-local sources: block matters more here than in a single-project repository. A monorepo has many contributors, and generation results should not depend on how each one configured their machine. Pinning to @v2.1.0 inside the committed file means everyone gets the same package shape. Prefer a tag or SHA over a branch.

    Regenerating the whole workspace

    The more interesting operation is running the root definition against the monorepo periodically, to see how far the packages have drifted:

    structkit generate -d --diff .struct.yaml .

    Every package is checked against its structure in one pass. An empty diff means the workspace is consistent. A non-empty one shows which package has the stale tsconfig.json or the missing lint config — with the correction already rendered.

    Applying it, conservatively:

    # Only add what's missing across every package
    structkit generate -f skip .struct.yaml .
    
    # Take the template's version of shared config, keeping backups
    structkit generate -f backup -b /tmp/monorepo-backup .struct.yaml .

    The -f strategies are overwrite (default), skip, append, rename, and backup. In a monorepo skip is usually too conservative for a routine sync — it never corrects the drifted tsconfig.json, which is the thing you actually wanted. The combination that works in practice is overwrite as the strategy plus skip_if_exists: true on the files packages own, so ownership is expressed in the definition rather than on the command line.

    Per-package data without per-package templates

    Ownership, Slack channels, and deployment targets are per-package data, not per-package structure. Putting them in a mappings file keeps one template serving every package:

    mappings/packages.yaml:

    mappings:
      packages:
        billing:
          team: payments
          slack: "#payments"
          deploy_target: ecs-prod-us-east-1
        shipping:
          team: fulfilment
          slack: "#fulfilment"
          deploy_target: ecs-prod-us-east-1
        api:
          team: platform
          slack: "#platform-team"
          deploy_target: ecs-prod-global
    files:
      - README.md:
          skip_if_exists: true
          content: |
            # @acme/{{@ package_name @}}
    
            **Owner:** @acme/{{@ mappings.packages[package_name].team @}}
            **Slack:** {{@ mappings.packages[package_name].slack @}}
      - deploy.yaml:
          content: |
            target: {{@ mappings.packages[package_name].deploy_target @}}
    structkit generate --mappings-file ./mappings/packages.yaml .struct.yaml .

    When a team renames its Slack channel, one line changes and the next regeneration carries it into every affected package. Multiple --mappings-file arguments merge in order, later overriding earlier, deep-merging nested dictionaries.

    Understanding the composition graph

    Once a monorepo definition composes a dozen structures, reading the YAML stops being the fastest way to understand it. Over MCP, graph_structure renders the dependency tree:

    {
      "name": "graph_structure",
      "arguments": {
        "structure_definition": ".struct.yaml",
        "output": "mermaid"
      }
    }

    It reports nested dependencies, missing references, and cycles. The missing-reference check earns its keep the first time someone renames a structure in the definitions repository and three packages quietly stop resolving.

    Keeping it honest in CI

    A drift check on every pull request, starting as a warning:

    name: workspace-consistency
    
    on: [pull_request]
    
    jobs:
      check:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v5
          - uses: actions/setup-python@v6
            with:
              python-version: '3.12'
          - run: pip install structkit==3.2.1
          - name: Check package scaffolds
            env:
              STRUCTKIT_NON_INTERACTIVE: "true"
            run: |
              structkit generate -d --diff .struct.yaml . | tee drift.txt
              if [ -s drift.txt ]; then
                echo "::warning::Workspace has drifted from package standards"
                cat drift.txt >> "$GITHUB_STEP_SUMMARY"
              fi

    Set STRUCTKIT_NON_INTERACTIVE to exactly true, 1, or yes — anything else, including "on" or a trailing space, is treated as false, and the job will hang waiting for a prompt that never gets answered.

    For the weekly correction, the reusable workflow opens a pull request with whatever changed:

    jobs:
      generate:
        uses: httpdss/structkit/.github/workflows/struct-generate.yaml@main
        with:
          args: "-f skip"
          pr_title: "chore: sync package scaffolds"
          version: "3.2.1"
        secrets:
          token: ${{ secrets.STRUCT_RUN_TOKEN }}

    What this does not solve

    Scaffolding converges the shape of packages. It does not manage the dependency graph between them, decide package boundaries, or speed up your build.

    Package boundaries in particular are a design problem that no generator will solve — if billing and shipping should not have been separate packages, generating both consistently just makes the mistake tidier. Get the boundaries right first.

    It also does not replace a workspace tool. Turborepo, Nx, pnpm workspaces, and uv do dependency resolution, task orchestration, and caching. StructKit creates the files those tools then operate on, and the two compose without overlapping.

    Wrap-up

    The monorepo argument is that shared code should live together. That argument holds, and it does not extend to package scaffolding, which by default gets copied from whichever package the author looked at last.

    Define the package shape once, compose the workspace from it, mark team-owned files with skip_if_exists, and run a dry-run diff on every pull request. Adding a package becomes a command, and drift becomes something you can see rather than something you discover.

    See YAML Configuration for folders: and with:, Custom Structures for file-local sources, and Mappings for per-package data.