Our repo is turning into “multiple projects, multiple languages”—what are the first build/dev environment guardrails to put in place?
Developer Productivity Tooling

Our repo is turning into “multiple projects, multiple languages”—what are the first build/dev environment guardrails to put in place?

10 min read

Modern codebases rarely stay small and single-language for long. One project becomes three, one language becomes five, and suddenly your repo feels like a crowded coworking space with no rules: everyone is productive until they start tripping over each other’s tools, dependencies, and build steps.

This is exactly when you need guardrails—not heavy process, but lightweight standards that make a “multiple projects, multiple languages” repo predictable for contributors, automation, and future you.

Below is a practical, opinionated checklist of the first build and dev-environment guardrails to put in place, in roughly the order most teams can adopt them.


1. Make the repo discoverable and predictable

Before worrying about advanced tooling, ensure that any developer can open the repo for the first time and quickly answer:

  • What’s here?
  • What is written in which language?
  • How do I build, test, and run each part?

1.1. Create a top-level README that acts like a “map”

At minimum, your README.md should include:

  • Repo overview

    • One or two sentences summarizing what the repo is for.
    • A diagram or bullet list of sub-projects / services.
  • Project directory map

    /services/
      api/           # Node.js/TypeScript REST API
      worker/        # Python async worker
    /frontend/
      web/           # React SPA
      admin/         # Admin dashboard
    /infra/          # IaC (Terraform, Pulumi, etc.)
    
  • High-level build & run instructions

    • Link to language-specific instructions (don’t duplicate everything in the root).
    • Show the “golden path” for getting everything running locally, even if some parts are optional.

This map is your first and most important guardrail: it tells people where things belong and what exists today.

1.2. Add per-project READMEs

Each sub-project or service (e.g., services/api, frontend/web) should have its own README.md containing:

  • What it does.
  • Language and major tooling (e.g., Node 20 + pnpm, Python 3.11 + Poetry).
  • How to:
    • Install dependencies.
    • Run tests.
    • Build/bundle.
    • Start locally (including env vars).

This keeps build/dev knowledge close to the code, instead of in tribal memory or chat history.


2. Standardize directory structure early

Chaos starts when every team invents its own layout. Put “just enough” structure in place so new projects don’t have to decide everything from scratch.

2.1. Use clear, top-level domains

Common top-level directories in a multi-project, multi-language repo:

  • /services – backend services (APIs, workers, cron jobs).
  • /frontend – web, mobile, or desktop clients.
  • /libs or /packages – shared libraries and modules.
  • /infra – Terraform, CDK, Pulumi, Helm charts, etc.
  • /tools – internal CLI tools, scripts, codegen.

The exact names aren’t critical; consistency is. Document the expectations in the main README:

  • New backend services go under /services/[service-name].
  • Shared code goes under /libs/[language]/[package-name] or similar.
  • Infra that deploys multiple services lives under /infra.

2.2. Enforce one primary language per sub-project

Mixed-language projects (e.g., TypeScript + Python in the same directory) quickly become unmanageable. Set an early rule:

  • Each sub-project directory should have one primary language, with a clear build/test flow.
  • Cross-language integration should happen between sub-projects, not inside them.

3. Define minimal, language-agnostic build conventions

In a “multiple projects, multiple languages” repo, you need a universal mental model: “every project has a standard set of actions, regardless of language.”

Define and document a minimal interface like:

  • build – compile / bundle / package the project.
  • test – run tests.
  • lint – run linters / static analysis.
  • format – apply formatting (if applicable).
  • start – start the app in dev mode (if applicable).

Then, for each language, wire these into the native tooling:

3.1. Node/TypeScript example (package.json scripts)

{
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "test": "vitest run",
    "lint": "eslint src --ext .ts,.tsx",
    "format": "prettier --write .",
    "start": "node dist/index.js"
  }
}

3.2. Python example (Poetry + Makefile)

pyproject.toml:

[tool.poetry.scripts]
start = "my_service.main:main"

Makefile:

build:
\tpoetry build

test:
\tpoetry run pytest

lint:
\tpoetry run ruff check .

format:
\tpoetry run ruff format .

start:
\tpoetry run my_service

3.3. Use a thin abstraction like make or task runners

To unify languages, you can:

  • Put language-agnostic targets in each project’s Makefile (build, test, lint, start).
  • Or use a task runner (e.g., just, taskfile, npm “umbrella” scripts) at the root to call into sub-projects.

Example root Makefile:

.PHONY: build test lint

build:
\t$(MAKE) -C services/api build
\t$(MAKE) -C services/worker build

test:
\t$(MAKE) -C services/api test
\t$(MAKE) -C services/worker test

lint:
\t$(MAKE) -C services/api lint
\t$(MAKE) -C services/worker lint

This gives you a consistent developer experience: make build and make test “just work” even across languages.


4. Lock down language runtimes and tool versions

One of the biggest sources of “works on my machine” in a multi-language repo is version drift:

  • Node 18 vs 20
  • Python 3.9 vs 3.11
  • Different pip/Poetry/npm versions
  • Different clang/gcc for native deps

Set guardrails so everyone (including CI) is using compatible versions.

4.1. Use version files at the repo root

Common tools:

  • .nvmrc or .node-version for Node.
  • .python-version for Python (pyenv).
  • .tool-versions for asdf to manage multiple runtimes.
  • Dockerfile for an authoritative, fully reproducible environment.

Even if some devs don’t use these tools, they act as documentation and can be used in CI images.

4.2. Pin dependency versions per project

Per-language best practices:

  • Node: lockfiles (package-lock.json, pnpm-lock.yaml, yarn.lock).
  • Python: Poetry (poetry.lock), pip-tools (requirements.txt + requirements.lock), or uv.lock.
  • Rust: Cargo.lock.
  • Go: go.mod and go.sum.

Guardrail: Do not allow merging without updated lockfiles when dependencies change.

4.3. Provide a canonical dev container (optional but powerful)

Consider a root-level devcontainer.json and/or Dockerfile.dev that:

  • Installs all required runtimes and CLI tools.
  • Sets up consistent OS-level dependencies (e.g., libpq-dev, build-essential).
  • Documents how to run:
docker compose up dev
# or VS Code Remote Containers / Dev Containers

This is especially valuable when your repo spans languages that require system libraries or complex tooling (e.g., Python + Node + Java + DB).


5. Establish basic CI as an extension of your local conventions

Once you have local commands (build, test, lint), your first CI guardrail is to run exactly those commands in automation.

5.1. Start with a single CI workflow that covers all projects

For example, in GitHub Actions:

name: CI

on:
  pull_request:
  push:
    branches: [ main ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version-file: .python-version

      - name: Install dependencies
        run: |
          make install  # if you define one
          # or call per-project installers

      - name: Lint
        run: make lint

      - name: Test
        run: make test

Later you can optimize: split by project, cache dependencies, run affected-only jobs. Early on, focus on having a green or red signal for each change.

5.2. Treat CI failures as non-negotiable

A cultural guardrail: no merging on red. This reinforces that your multi-language repo still has one standard of quality.


6. Normalize linting and formatting without over-optimizing

Different languages have strong opinions (Prettier, Black, gofmt). Your goal is to get to “no bikeshedding” as fast as possible.

6.1. Per-language lint/format choices

Examples:

  • TypeScript/JavaScript:
    • Lint: ESLint.
    • Format: Prettier.
  • Python:
    • Lint: Ruff, Flake8, or Pylint.
    • Format: Black or Ruff formatter.
  • Go:
    • Lint: golangci-lint.
    • Format: gofmt / goimports.
  • Rust:
    • Lint: clippy.
    • Format: rustfmt.

Wire them into your standard commands:

  • make lint
  • make format

Or language-specific package scripts.

6.2. Add pre-commit hooks (optional but helpful)

Use pre-commit or language-native hooks to:

  • Run formatters and basic linters on changed files, not the entire repo.
  • Prevent obviously-broken commits from ever being pushed.

Root .pre-commit-config.yaml can handle multiple languages and tools.


7. Introduce basic project isolation and boundaries

In a multi-project repo, it’s easy for boundaries to blur. Guardrails here are less about tooling and more about policy.

7.1. Clearly define what can depend on what

Document high-level dependency rules, e.g.:

  • Frontend apps can depend on shared frontend libs in /libs/frontend.
  • Backend services can depend on shared backend libs in /libs/backend.
  • No service can import code directly from another service’s directory.
  • Shared code must live in /libs/....

This avoids tangled imports and makes it easier to extract services or packages later.

7.2. Use tooling to enforce boundaries where possible

Depending on your stack:

  • TypeScript: path aliases + ESLint rules to prevent cross-project imports.
  • Bazel / Nx / Turborepo / Gradle: build graph with explicit dependencies.
  • Simple approach: keep shared code in separate packages with versioned imports (even if local).

You don’t have to adopt a full monorepo tool immediately, but make it harder to accidentally couple projects.


8. Centralize environment configuration patterns

Multiple projects + multiple languages usually means lots of environment variables, config files, and secrets. Early guardrails prevent:

  • “It works locally but not in staging.”
  • “Which .env file does this service use?”

8.1. Standardize on how env vars are loaded

For example:

  • Each service:
    • Reads from .env.local in dev (not committed).
    • Reads from environment variables in CI/prod.
  • Use a consistent library per language:
    • Node: dotenv, or framework-specific config.
    • Python: pydantic-settings, python-dotenv.

Document:

  • Where to define env vars for each service.
  • How to override them locally.
  • Which vars are required vs optional.

8.2. Do not commit secrets—add guardrails

  • Add .env* to .gitignore by default.
  • Add a root SECURITY.md or section in README reminding developers not to commit secrets, with links to secret management (e.g., Vault, AWS Secrets Manager).

9. Provide a single “bootstrap” path for new devs

The fastest way to feel the pain of “multiple projects, multiple languages” is onboarding someone new. Turn that into a test case for your guardrails.

9.1. Add a root-level “Getting Started” section

In README.md, include:

  1. Prerequisites

    • “Install Docker, asdf, Node 20, Python 3.11.”
    • Or: “Use our dev container.”
  2. One-command bootstrap (as much as possible)

    • make bootstrap
    • Or ./scripts/bootstrap.sh

Example make target:

bootstrap:
\tasdf install || true
\tcd services/api && npm install
\tcd services/worker && poetry install
  1. First-run instructions
    • “Run make start-dev to spin up API + frontend + DB.”
    • “Run make test to confirm your environment is healthy.”

9.2. Treat new-hire feedback as design input

Make it explicit: if a new dev hits friction, file issues and improve your guardrails. This keeps your repo navigable as the language and project count grows.


10. Decide what not to do yet

It’s tempting to jump straight into heavy monorepo tooling or deeply optimized pipelines. For a repo just turning into “multiple projects, multiple languages,” these are usually second-wave concerns:

  • Full Bazel adoption.
  • Complex Nx/Turborepo setups.
  • Per-PR selective builds via dependency graph analysis.
  • Fully bespoke dev environment managers.

Instead, your first guardrails should prioritize:

  1. Clarity – Everyone understands what lives where.
  2. Consistency – Commands and structure feel similar across projects.
  3. Reproducibility – Same runtimes and dependencies everywhere.
  4. Automation – CI runs the same build/test steps devs use locally.
  5. Boundaries – Projects don’t entangle by accident.

When those are solid, you’ll be in a much better position to decide whether you need heavier monorepo tooling, more advanced caching, or more granular pipelines.


Quick checklist you can apply this week

To make this actionable, here’s a condensed list for your “our repo is turning into multiple projects, multiple languages” moment:

  • Root README that maps projects and explains how to build/test/run them.
  • Per-project READMEs with language, tools, and commands.
  • Agreed directory structure (e.g., /services, /frontend, /libs, /infra).
  • Standard commands per project: build, test, lint, format, start.
  • Basic root-level orchestration (e.g., Makefile or task runner).
  • Runtime version files (.nvmrc, .python-version, .tool-versions, etc.).
  • Dependency lockfiles for each language.
  • Minimal CI pipeline that runs lint and test for all projects.
  • Lint/format tools configured per language and wired into commands.
  • Clear env var / config pattern and .env* in .gitignore.
  • A “Getting Started” section and/or a bootstrap script at repo root.
  • Documented rules for cross-project dependencies and shared code locations.

Put these guardrails in place and your multi-project, multi-language repo will feel less like a chaotic sprawl and more like a well-organized campus: plenty of freedom to build things, with just enough structure that nobody gets lost.