{"slug": "polyglot-monorepo-magic-typescript-python-and-go-in-one-repo", "title": "Polyglot Monorepo Magic: TypeScript, Python, and Go in One Repo", "summary": "A developer built a polyglot monorepo called `beacon-monorepo` that houses a real-time analytics platform with services written in TypeScript, Go, and Python. The repository uses three separate workspace managers—pnpm for TypeScript, uv for Python, and Go workspaces—that coexist by keying on different file extensions, with Protobuf definitions in `proto/` serving as the single source of truth for cross-language contracts. The setup eliminates coordination problems between frontend, API, and ML teams by allowing them to share contracts and tooling within one Git repository while maintaining language-specific package management.", "body_md": "A **polyglot monorepo** is a single Git repository containing services and packages written in multiple programming languages. The alternative — a polyrepo — means one repo per service, per team, per language. When your frontend team, your Go API team, and your Python ML team all operate in separate repos, sharing contracts between them becomes a coordination problem.\n\nThis repo is `beacon-monorepo`\n\n. It's a real-time analytics platform. It contains:\n\n```\nbeacon-monorepo/\n├── frontend/\n│   └── web/           ← Next.js dashboard (TypeScript)\n├── services/\n│   ├── api/           ← REST API (Go)\n│   ├── ingest/        ← Data ingestion service (Go)\n│   ├── ml/            ← ML inference API (Python / FastAPI)\n│   └── worker/        ← Background jobs (Python / Celery)\n├── packages/\n│   ├── ui/            ← Shared React components (TypeScript)\n│   └── sdk/           ← TypeScript client SDK\n├── pkg/\n│   ├── shared/        ← Go shared utilities\n│   └── store/         ← Go data access layer\n├── libs/\n│   └── shared/        ← Python shared models + Pydantic schemas\n├── proto/             ← Protobuf definitions (source of truth)\n├── gen/\n│   ├── go/            ← Generated Go stubs\n│   ├── python/        ← Generated Python stubs\n│   └── ts/            ← Generated TypeScript stubs\n├── Taskfile.yml       ← Root task orchestration (cross-language)\n├── pnpm-workspace.yaml\n├── package.json       ← Root (biome, lefthook, turbo)\n├── pyproject.toml     ← uv workspace root\n├── uv.lock\n├── biome.json\n├── .golangci.yml\n└── go.work            ← (gitignored — local dev only)\n```\n\n**The core idea:** services are owned by different teams writing different languages, but they share contracts defined in `proto/`\n\n, config defined at the repo root, and a single task runner (`Taskfile.yml`\n\n) that orchestrates everything with one set of commands.\n\nThere is no single package manager that handles TypeScript, Python, and Go. Instead, three workspace managers coexist in the same repo — each governing its own language, each completely ignorant of the others.\n\n| Language | Manager | Config file | Dep linking mechanism |\n|---|---|---|---|\n| TypeScript | pnpm workspaces | `pnpm-workspace.yaml` |\n`workspace:*` symlinks |\n| Python | uv workspaces | root `pyproject.toml`\n|\n`{ workspace = true }` editable installs |\n| Go | go workspaces | `go.work` |\nlocal module overlay |\n\nThey coexist peacefully because they key on different file extensions. pnpm reads `package.json`\n\n. uv reads `pyproject.toml`\n\n. Go reads `go.mod`\n\n. A directory like `services/ml/`\n\nthat contains a `pyproject.toml`\n\nis invisible to pnpm. A directory with only a `package.json`\n\nis invisible to uv.\n\n`pnpm-workspace.yaml`\n\n```\npackages:\n  - 'frontend/*'\n  - 'packages/*'\n  - 'gen/ts'          # generated TypeScript stubs — must be a workspace member\n```\n\n`gen/ts/`\n\n**must** be a workspace member, not a `file:`\n\ndependency. A `file:`\n\nreference copies the package into `node_modules`\n\nat install time — after `buf generate`\n\nregenerates the stubs, the copies are stale until you re-run `pnpm install`\n\n. A `workspace:*`\n\nreference is a symlink — always live.\n\n`packages/sdk/package.json`\n\ndepends on the shared UI and the generated TypeScript stubs:\n\n```\n{\n  \"name\": \"@beacon/sdk\",\n  \"dependencies\": {\n    \"@beacon/ui\": \"workspace:*\",\n    \"@beacon/proto-ts\": \"workspace:*\"   // symlinked — reflects buf generate immediately\n  }\n}\n```\n\n`pyproject.toml`\n\n```\n[project]\nname = \"beacon-root\"\nversion = \"0.1.0\"\nrequires-python = \">=3.12\"\n\n[tool.uv.workspace]\n# List Python members explicitly — globs must not match dirs without pyproject.toml\nmembers = [\n    \"services/ml\",\n    \"services/worker\",\n    \"libs/shared\",\n    \"gen/python\",      # generated proto stubs — must be listed so uv can resolve them\n]\n\n[tool.uv.sources]\nshared = { workspace = true }   # the workspace:* equivalent for Python\n```\n\n`services/ml/pyproject.toml`\n\ndeclares its internal dep:\n\n```\n[project]\nname = \"ml\"\ndependencies = [\n    \"shared\",           # internal — resolves via workspace\n    \"fastapi>=0.115\",\n    \"torch>=2.3\",\n]\n\n[tool.uv.sources]\nshared = { workspace = true }\n```\n\n**Why explicit members, not globs?** uv errors if a glob matches a directory without a `pyproject.toml`\n\n. `frontend/web/`\n\nand `packages/ui/`\n\nhave `package.json`\n\nbut no `pyproject.toml`\n\n— a glob like `packages/*`\n\nwould break uv. Be explicit.\n\n`go.work`\n\n```\ngo 1.23\n\nuse (\n    ./services/api\n    ./services/ingest\n    ./pkg/shared\n    ./pkg/store\n    ./gen/go          ← generated proto stubs (own go.mod)\n)\n```\n\n**Critical:** add `go.work`\n\nand `go.work.sum`\n\nto `.gitignore`\n\n. CI must validate each Go module against its own `go.mod`\n\nindependently. The workspace is a local dev convenience — not a CI mechanism.\n\n`go.mod`\n\nplumbing — how CI works without `go.work`\n\nThis is the part most polyglot monorepo guides skip. When CI runs `GOWORK=off`\n\n, each Go module must declare its in-repo dependencies in its own `go.mod`\n\nusing `replace`\n\ndirectives — otherwise the module can't find `gen/go`\n\nor `pkg/shared`\n\n.\n\n`services/api/go.mod`\n\n:\n\n```\nmodule github.com/your-org/beacon/services/api\n\ngo 1.23\n\nrequire (\n    github.com/your-org/beacon/gen/go    v0.0.0\n    github.com/your-org/beacon/pkg/shared v0.0.0\n    // external deps...\n)\n\n// replace directives make GOWORK=off CI work:\n// each module explicitly maps its in-repo deps to disk paths\nreplace (\n    github.com/your-org/beacon/gen/go     => ../../gen/go\n    github.com/your-org/beacon/pkg/shared => ../../pkg/shared\n)\n```\n\n`go work sync`\n\npropagates the `require`\n\nversions from each `go.mod`\n\n. The `replace`\n\ndirectives work with or without `go.work`\n\n— so `GOWORK=off`\n\nCI and `go.work`\n\nlocal dev both resolve to the same local disk paths.\n\nRun `go mod tidy`\n\nin each module after adding the `replace`\n\ndirectives. The `v0.0.0`\n\nversion is a placeholder that `go mod tidy`\n\nfills in as a pseudo-version.\n\n```\n# .gitignore\ngo.work\ngo.work.sum\n.task/\n.venv/\nnode_modules/\n.turbo/\n.next/\n__pycache__/\n*.pyc\nservices/*/bin/\n```\n\n`uv sync`\n\nand `pnpm install`\n\nand `go work sync`\n\neach do\n\n```\npnpm install          # links TypeScript packages via symlinks in node_modules\nuv sync --group dev   # installs all Python workspace members as editable installs\ngo work init          # creates go.work on a fresh clone (only needed once — it's gitignored)\ngo work use ./services/api ./services/ingest ./pkg/shared ./pkg/store ./gen/go\ngo work sync          # syncs go.mod files across Go modules (run after adding new deps)\n```\n\n**Fresh clone problem:** `go.work`\n\nis gitignored, so a fresh clone has no workspace file. The `go work init`\n\n+ `go work use`\n\nsteps only run once per machine. The Go Taskfile handles this automatically.\n\n**The key insight:** these three commands are completely independent. Running `pnpm install`\n\ndoes not affect Python deps. Running `uv sync`\n\ndoes not touch `node_modules`\n\n. None of them touch each other. The three workspace managers are parallel foundations — they don't compose, they coexist.\n\nThree workspace managers handle *dependencies*. **Task** handles *tasks* (build, test, lint, generate, dev). It's the single entry point for everything in the repo, regardless of language.\n\nTask is a language-agnostic binary. Install it once:\n\n```\nbrew install go-task\n# or: sh -c \"$(curl --location https://taskfile.dev/install.sh)\" -- -d -b ~/.local/bin\n```\n\n`Taskfile.yml`\n\n```\nversion: '3'\n\nincludes:\n  ts:\n    taskfile: ./Taskfile.ts.yml\n    optional: true\n  py:\n    taskfile: ./Taskfile.py.yml\n    optional: true\n  go:\n    taskfile: ./Taskfile.go.yml\n    optional: true\n  proto:\n    taskfile: ./Taskfile.proto.yml\n    optional: true\n\ntasks:\n  doctor:\n    desc: Verify all required tools are installed\n    cmds:\n      - command -v node  || (echo \"node not found — https://nodejs.org\"  && exit 1)\n      - command -v pnpm  || (echo \"pnpm not found — npm install -g pnpm\" && exit 1)\n      - command -v uv    || (echo \"uv not found — https://docs.astral.sh/uv\" && exit 1)\n      - command -v go    || (echo \"go not found — https://go.dev/dl\" && exit 1)\n      - command -v buf   || (echo \"buf not found — https://buf.build/docs/cli\" && exit 1)\n      - command -v jq    || (echo \"jq not found — brew install jq\" && exit 1)\n      - echo \"All tools present\"\n\n  setup:\n    desc: Install all dependencies (run task doctor first)\n    deps: [doctor]\n    cmds:\n      - pnpm install\n      - uv sync --group dev   # single .venv at repo root; --group dev includes pytest/mypy/ruff\n      - task: go:init         # creates go.work if missing (idempotent)\n      - go work sync\n      - task: proto:generate\n\n  build:all:\n    desc: Build everything in dependency order\n    cmds:\n      - task: proto:generate       # proto first — all languages depend on it\n      - task: ts:build\n      - task: go:build\n      - task: py:build\n\n  test:all:\n    desc: Test all languages in parallel\n    deps:\n      - ts:test\n      - go:test\n      - py:test\n\n  lint:all:\n    desc: Lint all languages in parallel\n    deps:\n      - ts:lint\n      - go:lint\n      - py:lint\n\n  dev:\n    desc: Start all dev servers\n    deps:\n      - ts:dev\n      - go:dev\n      - py:dev\n```\n\n`Taskfile.ts.yml`\n\n— TypeScript:\n\n```\nversion: '3'\n\ntasks:\n  build:\n    sources:\n      - 'frontend/**/*.ts'\n      - 'frontend/**/*.tsx'\n      - 'packages/**/*.ts'\n      - '**/package.json'     # dep changes should bust the cache\n      - 'pnpm-lock.yaml'\n    generates: ['frontend/web/.next/**']\n    cmds:\n      - pnpm turbo run build\n\n  lint:\n    sources: ['frontend/**/*.ts', 'packages/**/*.ts', '**/package.json']\n    generates: ['.task/ts-lint.stamp']\n    cmds:\n      - pnpm biome check .\n      - touch .task/ts-lint.stamp\n\n  test:\n    sources:\n      - 'frontend/**/*.ts'\n      - 'packages/**/*.ts'\n      - 'frontend/**/*.test.ts'\n      - 'pnpm-lock.yaml'\n    cmds:\n      - pnpm turbo run test\n\n  dev:\n    cmds:\n      - pnpm turbo run dev --filter=@beacon/web\n```\n\n`Taskfile.go.yml`\n\n— Go:\n\n```\nversion: '3'\n\n# Prerequisite: jq must be installed (brew install jq / apt install jq)\n\ntasks:\n  init:\n    desc: Create go.work for local development (gitignored; run once per clone)\n    status:\n      - test -f go.work    # skip if go.work already exists\n    cmds:\n      - go work init\n      - go work use ./services/api ./services/ingest ./pkg/shared ./pkg/store ./gen/go\n\n  build:\n    deps: [init]           # ensures go.work exists before building\n    sources:\n      - 'services/api/**/*.go'\n      - 'services/ingest/**/*.go'\n      - 'pkg/**/*.go'\n      - '**/go.mod'        # dep changes should bust the cache\n      - '**/go.sum'\n    generates: ['services/api/bin/api', 'services/ingest/bin/ingest']\n    cmds:\n      - cd services/api && go build -o bin/api ./cmd/api\n      - cd services/ingest && go build -o bin/ingest ./cmd/ingest\n\n  lint:\n    deps: [init]\n    sources: ['services/**/*.go', 'pkg/**/*.go']\n    generates: ['.task/go-lint.stamp']\n    cmds:\n      - go work edit -json | jq -r '.Use[].DiskPath' |\n          xargs -P4 -I{} sh -c 'cd {} && golangci-lint run ./...'\n      - touch .task/go-lint.stamp\n\n  test:\n    deps: [init]\n    sources: ['services/**/*.go', 'pkg/**/*.go']\n    cmds:\n      - go work edit -json | jq -r '.Use[].DiskPath' |\n          xargs -P4 -I{} sh -c 'cd {} && go test ./... -race'\n\n  dev:\n    deps: [init]\n    cmds:\n      - cd services/api && go run ./cmd/api\n```\n\n`Taskfile.py.yml`\n\n— Python:\n\n```\nversion: '3'\n\ntasks:\n  build:\n    sources:\n      - 'services/ml/**/*.py'\n      - 'services/worker/**/*.py'\n      - 'libs/**/*.py'\n      - '**/pyproject.toml'  # dep changes should bust the cache\n      - 'uv.lock'\n    generates: ['.task/py-build.stamp']\n    cmds:\n      - uv sync --group dev\n      - touch .task/py-build.stamp\n\n  lint:\n    sources: ['services/ml/**/*.py', 'services/worker/**/*.py', 'libs/**/*.py', 'uv.lock']\n    generates: ['.task/py-lint.stamp']\n    cmds:\n      - uv run ruff check services/ml services/worker libs\n      - uv run ruff format --check services/ml services/worker libs\n      - uv run mypy services/ml/ services/worker/ libs/   # from repo root — not per-service\n      - touch .task/py-lint.stamp\n\n  test:\n    sources: ['services/ml/**/*.py', 'services/worker/**/*.py', 'libs/**/*.py']\n    cmds:\n      - uv run pytest services/ml services/worker libs -x\n\n  dev:\n    cmds:\n      - uv run fastapi dev services/ml/src/ml/main.py\n```\n\n**Key concepts:**\n\n`deps:`\n\nruns tasks in `test:all`\n\nruns all three test suites simultaneously`cmds:`\n\nruns `build:all`\n\nruns proto first, then the rest`sources`\n\n/`generates`\n\nis Task's caching: if source files haven't changed since the last run (hash stored in `.task/`\n\n), the task is skipped entirely`.task/`\n\nto `.gitignore`\n\n`task build:all`\n\nrequires `task build:ts`\n\n, `task build:go`\n\n, `task build:py`\n\nas the normal developer interface.Task has no built-in `--affected`\n\n. In CI, detect changed language areas with git diff:\n\n```\n# ci/affected.sh\nchanged=$(git diff --name-only origin/main...HEAD)\n\necho \"$changed\" | grep -qE '^(frontend|packages)/' && task ts:test\necho \"$changed\" | grep -qE '^(services/(api|ingest)|pkg)/' && task go:test\necho \"$changed\" | grep -qE '^(services/(ml|worker)|libs)/' && task py:test\necho \"$changed\" | grep -qE '^proto/' && {\n  task proto:generate\n  task ts:test && task go:test && task py:test  # proto change affects all\n}\n```\n\nA PR touching only `services/ml/`\n\nwon't re-run Go tests.\n\nThis is the tool that makes a polyglot monorepo genuinely worth the complexity. A single `.proto`\n\nfile becomes the source of truth for how your TypeScript frontend, your Go API, and your Python ML service all communicate. Change the proto once — regenerate — all three languages are in sync.\n\n`buf.yaml`\n\n— workspace config at repo root\n\n```\n# buf.yaml\nversion: v2\n\nmodules:\n  - path: proto/analytics\n    name: buf.build/your-org/analytics\n\n  - path: proto/events\n    name: buf.build/your-org/events\n\ndeps:\n  - buf.build/googleapis/googleapis\n\nlint:\n  use: [STANDARD]\n  except: [PACKAGE_DIRECTORY_MATCH]\n\nbreaking:\n  use: [FILE]\n```\n\n`buf.gen.yaml`\n\n— one command, three languages\n\n```\n# buf.gen.yaml\nversion: v2\n\ninputs:\n  - directory: proto/analytics\n  - directory: proto/events\n\nplugins:\n  # Go: message stubs\n  - remote: buf.build/protocolbuffers/go\n    out: gen/go\n    opt: paths=source_relative\n\n  # Go: gRPC service stubs\n  - remote: buf.build/grpc/go\n    out: gen/go\n    opt: paths=source_relative\n\n  # Python: message stubs\n  - remote: buf.build/protocolbuffers/python\n    out: gen/python\n\n  # Python: gRPC service stubs\n  - remote: buf.build/grpc/python\n    out: gen/python\n\n  # Python: mypy type stubs (community plugin)\n  - remote: buf.build/community/nipunn1313-mypy\n    out: gen/python\n\n  # TypeScript: Protobuf-ES (modern, works with Connect-RPC)\n  - remote: buf.build/bufbuild/es\n    out: gen/ts\n    opt: target=ts\n\nmanaged:\n  enabled: true\n  override:\n    - file_option: go_package_prefix\n      value: github.com/your-org/beacon/gen/go\n```\n\nRun `buf generate`\n\nto regenerate all three at once.\n\nEach generated directory is its own module with its own manifest — so the three workspace managers can find it:\n\n```\ngen/\n├── go/\n│   └── go.mod          ← module github.com/your-org/beacon/gen/go\n│                          each consumer's go.mod has: replace ... => ../../gen/go\n├── python/\n│   └── pyproject.toml  ← listed in uv workspace members; consumers use { workspace = true }\n└── ts/\n    └── package.json    ← listed in pnpm-workspace.yaml; consumers use workspace:*\n```\n\nAll three use the same pattern as other internal packages — workspace linking, not `file:`\n\ncopies. `gen/go/`\n\nis also listed in `go.work`\n\nfor local dev, and each consumer's `go.mod`\n\nhas a `replace`\n\ndirective for CI.\n\n```\n# Taskfile.proto.yml\ntasks:\n  generate:\n    sources:\n      - proto/**/*.proto\n    generates:\n      - gen/go/**/*.go\n      - gen/python/**/*_pb2.py\n      - gen/ts/**/*_pb.ts\n    cmds:\n      - buf lint\n      - buf generate\n      - buf breaking --against '.git#branch=main'\n```\n\n**Why this matters:** the breaking change check (`buf breaking`\n\n) is the safety net. If a Go developer renames a proto field, `buf breaking`\n\nfails the build before the Python and TypeScript consumers break. The contract is enforced at the definition layer, not the consumer layer.\n\nEach language has one config file at the repo root. No duplication across services, no \"why is lint disabled in this one service\" surprises.\n\n`biome.json`\n\n— TypeScript\n\n```\n{\n  \"$schema\": \"https://biomejs.dev/schemas/1.9.0/schema.json\",\n  \"linter\": {\n    \"rules\": {\n      \"correctness\": {\n        \"noUnusedVariables\": \"error\",\n        \"noUnusedImports\": \"error\"\n      },\n      \"style\": { \"useConst\": \"error\" }\n    }\n  },\n  \"formatter\": {\n    \"indentStyle\": \"space\",\n    \"indentWidth\": 2,\n    \"lineWidth\": 100\n  },\n  \"files\": {\n    \"ignore\": [\"gen/ts/**\", \"**/node_modules/**\", \"**/.next/**\"]\n  }\n}\n```\n\n`pyproject.toml`\n\n— Python lint + type config\n\n```\n[tool.ruff]\nline-length = 88\ntarget-version = \"py312\"\nsrc = [\"src\"]\nexclude = [\"gen/python\"]\n\n[tool.ruff.lint]\nselect = [\"E\", \"W\", \"F\", \"I\", \"B\", \"UP\"]\nignore = [\"E501\"]\nfixable = [\"ALL\"]\n\n[tool.ruff.lint.isort]\nknown-first-party = [\"shared\", \"ml\", \"worker\"]\n\n[tool.mypy]\npython_version = \"3.12\"\nstrict = true\nmypy_path = \"$MYPY_CONFIG_FILE_DIR/libs/shared/src\"\nexclude = [\"gen/python\"]\n\n[[tool.mypy.overrides]]\nmodule = [\"*_pb2\", \"*_pb2_grpc\"]\nignore_errors = true   # suppress errors in generated proto stubs\n\n[dependency-groups]\ndev = [\n    \"pytest>=8\",\n    \"pytest-asyncio>=0.25\",\n    \"mypy>=1.15\",\n    \"ruff>=0.9\",\n]\n```\n\n**Critical — mypy invocation:** mypy reads config from the directory it's *invoked from*, not from the file being checked. Always run `uv run mypy services/ml/ services/worker/ libs/`\n\nfrom the repo root. Running from inside `services/ml/`\n\nsilently ignores `strict = true`\n\nand falls back to defaults.\n\n`.golangci.yml`\n\n— Go\n\n```\nversion: \"2\"\n\nrun:\n  timeout: 5m\n  tests: true\n\nlinters:\n  default: none\n  enable:\n    - errcheck\n    - staticcheck\n    - unused\n    - ineffassign\n    - govet\n    - revive\n    - gocritic\n    - misspell\n    - gosec\n    - bodyclose\n    - copyloopvar\n\n  exclusions:\n    generated: strict\n    rules:\n      - path: \"gen/go/.*\\\\.pb\\\\.go$\"\n        linters: [revive, gocritic, godot, errcheck]\n      - path: \"_test\\\\.go$\"\n        linters: [gosec, errcheck]\n\nformatters:\n  enable:\n    - gofumpt\n    - goimports\n  settings:\n    goimports:\n      local-prefixes:\n        - github.com/your-org/beacon\n```\n\n`.editorconfig`\n\n— baseline cross-language consistency\nBiome, Ruff, and golangci-lint enforce formatting within each language, but editors need `.editorconfig`\n\nfor baseline consistency before any tool runs — charset, line endings, trailing newlines:\n\n```\n# .editorconfig\nroot = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.go]\nindent_style = tab\nindent_size = 4\n\n[*.{ts,tsx,js,json,yaml,yml,toml}]\nindent_style = space\nindent_size = 2\n\n[*.py]\nindent_style = space\nindent_size = 4\n\n[*.proto]\nindent_style = space\nindent_size = 2\n```\n\n**Why this matters:** three config files, all at the repo root. Each tool finds its own by walking up the directory tree. You can change the Go linting rules for every Go service in one edit, the Python formatting rules for every Python service in one edit, and the TypeScript rules for every TypeScript package in one edit. No per-service drift.\n\n`task lint`\n\nCommand\nEach language uses its own best-in-class linter. Task unifies them:\n\n```\ntask lint:all   # runs all three in parallel (deps: is parallel)\ntask ts:lint    # TypeScript only\ntask go:lint    # Go only\ntask py:lint    # Python only\n```\n\nThere is no cross-language linter that handles TypeScript + Python + Go well. The best tools are language-specific:\n\nTask makes them feel like one: `task lint:all`\n\nexits non-zero if any of them fails, regardless of language.\n\n```\nproto/analytics/*.proto\nproto/events/*.proto\n    ↓ buf generate\n    ↓\ngen/go/    gen/python/    gen/ts/\n    ↓             ↓              ↓\npkg/shared   libs/shared    packages/sdk\n    ↓             ↓              ↓\nservices/api  services/ml   frontend/web\nservices/ingest  services/worker\n```\n\nA change to `proto/`\n\nforces all three build chains to run. Everything else is independent — a change to `services/api/`\n\ndoesn't rerun Python tests.\n\n```\n# .github/workflows/ci.yml\njobs:\n  detect:\n    runs-on: ubuntu-latest\n    outputs:\n      proto: ${{ steps.changes.outputs.proto }}\n      ts: ${{ steps.changes.outputs.ts }}\n      go: ${{ steps.changes.outputs.go }}\n      py: ${{ steps.changes.outputs.py }}\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - uses: dorny/paths-filter@v3\n        id: changes\n        with:\n          filters: |\n            proto:\n              - 'proto/**'\n            ts:\n              - 'frontend/**'\n              - 'packages/**'\n              - 'gen/ts/**'\n              - 'proto/**'   # proto change triggers TS too\n            go:\n              - 'services/api/**'\n              - 'services/ingest/**'\n              - 'pkg/**'\n              - 'gen/go/**'\n              - 'proto/**'\n            py:\n              - 'services/ml/**'\n              - 'services/worker/**'\n              - 'libs/**'\n              - 'gen/python/**'\n              - 'proto/**'\n\n  proto:\n    needs: detect\n    if: needs.detect.outputs.proto == 'true'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - run: |\n          npm install -g @bufbuild/buf\n          buf lint && buf generate\n          buf breaking --against '.git#branch=main'\n\n  ts:\n    needs: detect\n    if: needs.detect.outputs.ts == 'true'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0   # required for turbo --affected\n      - uses: pnpm/action-setup@v4\n      - run: pnpm install --frozen-lockfile   # equivalent of uv sync --frozen\n      - run: pnpm turbo run typecheck lint test --affected\n\n  go:\n    needs: detect\n    if: needs.detect.outputs.go == 'true'\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        module: [services/api, services/ingest, pkg/shared, pkg/store]\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-go@v5\n        with:\n          go-version-file: ${{ matrix.module }}/go.mod\n          cache-dependency-path: ${{ matrix.module }}/go.sum\n      # GOWORK=off is explicit — not relying on go.work being gitignored\n      # This forces each module to prove it has complete go.mod declarations\n      - run: cd ${{ matrix.module }} && GOWORK=off golangci-lint run ./...\n      - run: cd ${{ matrix.module }} && GOWORK=off go test ./... -race\n\n  py:\n    needs: detect\n    if: needs.detect.outputs.py == 'true'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: astral-sh/setup-uv@v5\n        with:\n          enable-cache: true\n      - run: uv sync --frozen --group dev\n        # --frozen: fail if uv.lock is out of date (never silently update in CI)\n        # --group dev: includes pytest, mypy, ruff\n      - run: uv run ruff check services/ml/ services/worker/ libs/\n      - run: uv run mypy services/ml/ services/worker/ libs/\n        # always from repo root — mypy does not discover config per-file like Ruff\n      - run: uv run pytest services/ml/ services/worker/ libs/ -x\n      - run: uv cache prune --ci\n```\n\nA PR touching only `services/ml/`\n\nskips the `proto`\n\n, `ts`\n\n, and `go`\n\njobs entirely.\n\nEach language article recommended its own hook tool (custom `.githooks/`\n\nfor TypeScript, pre-commit for Python, lefthook for Go). In a polyglot repo, you pick one. **lefthook** wins: it's a single binary, works with all languages, and runs hooks in parallel by default.\n\nlefthook is managed as a root-level dev dependency so everyone on the team gets it:\n\n```\n// package.json (root)\n{\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"^1.9.0\",\n    \"lefthook\": \"^1.11.0\",\n    \"turbo\": \"^2.0.0\"\n  },\n  \"scripts\": {\n    \"prepare\": \"lefthook install\"\n  }\n}\n```\n\n`pnpm install`\n\nruns `prepare`\n\nautomatically — lefthook hooks are wired up on first install.\n\n`lefthook.yml`\n\n```\n# lefthook.yml\nglob_matcher: doublestar  # ** matches 0 or more dirs (required for nested files)\n\npre-commit:\n  parallel: true\n  jobs:\n    - name: ts-lint\n      glob: \"**/*.{ts,tsx}\"\n      run: pnpm biome check --no-errors-on-unmatched {staged_files}\n\n    - name: py-lint\n      glob: \"**/*.py\"\n      run: uv run ruff check {staged_files}\n      stage_fixed: true\n\n    - name: py-format\n      glob: \"**/*.py\"\n      run: uv run ruff format {staged_files}\n      stage_fixed: true\n\n    - name: proto-lint\n      glob: \"**/*.proto\"\n      run: buf lint\n\npre-push:\n  parallel: false\n  jobs:\n    - name: go-lint\n      glob: \"**/*.go\"\n      # golangci-lint cannot lint files across multiple packages (named files must all\n      # be in one directory — see golangci-lint#3715). Run per-module instead:\n      run: go work edit -json | jq -r '.Use[].DiskPath' |\n             xargs -P4 -I{} sh -c 'cd {} && GOWORK=off golangci-lint run ./...'\n\n    - name: go-mod-tidy\n      glob: \"go.{mod,sum}\"\n      # go mod tidy is per-module in a multi-module workspace; iterate over all modules:\n      run: go work edit -json | jq -r '.Use[].DiskPath' |\n             xargs -I{} sh -c 'cd {} && GOWORK=off go mod tidy && git diff --exit-code go.mod go.sum'\n\n    - name: proto-breaking\n      glob: \"**/*.proto\"\n      # Guard against first push / no origin/main (new repos, shallow clones)\n      run: |\n        if git rev-parse origin/main >/dev/null 2>&1; then\n          buf breaking --against '.git#branch=main'\n        else\n          echo \"No origin/main — skipping breaking change check\"\n        fi\n\n    - name: py-typecheck\n      glob: \"**/*.py\"\n      # mypy is 10-30s on real codebases — too slow for pre-commit, right for pre-push\n      # pass whole dirs, not {staged_files}: mypy needs package-level context\n      run: uv run mypy services/ml/ services/worker/ libs/ --config-file pyproject.toml\n```\n\n**Key concepts:**\n\n`glob_matcher: doublestar`\n\n— without this, `**/*.py`\n\nwon't match `services/ml/src/ml/main.py`\n\n(two directories deep). This single line makes nested file matching work correctly.`go-lint`\n\nat all.`stage_fixed: true`\n\n— after Ruff auto-fixes a Python file, lefthook re-stages it so the fix is included in the commit.`parallel: true`\n\n— all four language checkers run simultaneously. On a fast machine, a commit touching files in all three languages still completes in the time of the slowest single checker.`pre-push`\n\nruns `buf breaking`\n\n— the breaking change check is too slow for pre-commit but important before pushing to a shared branch.**The key insight:** you don't need different hook tools for different languages. lefthook routes by file glob. The Python and Go developers on your team never need to know that Biome exists; the TypeScript developers never need to know that Ruff exists. They all run `pnpm install`\n\nonce and get the right checks for their files automatically.\n\n```\nDeveloper commits\n    ↓\nlefthook pre-commit (all four checks in parallel)\n    ↓ Biome on staged .ts/.tsx files\n    ↓ Ruff on staged .py files (+ auto-fix + re-stage)\n    ↓ golangci-lint on staged .go files\n    ↓ buf lint on staged .proto files\n    ↓ (any failure → abort commit; all pass → continue)\n    ↓\nThree workspace managers resolve internal deps in parallel:\n    ↓ pnpm: workspace:* symlinks for TypeScript\n    ↓ uv: { workspace = true } editable installs for Python\n    ↓ go work: local module overlay for Go (gitignored)\n    ↓\ntask build:all\n    ↓ proto:generate (buf → gen/go/ + gen/python/ + gen/ts/)\n    ↓ ts:build (pnpm turbo — dep-ordered, cached in .turbo/)\n    ↓ go:build (per-module, cached in .task/)\n    ↓ py:build (uv sync, cached in .task/)\n    ↓\nCI: detect-changes → three parallel language jobs\n    ↓ proto change → all three jobs + breaking check\n    ↓ TypeScript: pnpm turbo --affected (fetch-depth: 0)\n    ↓ Go: GOWORK=off → matrix per module → go test -race\n    ↓ Python: uv run pytest (affected services)\n    ↓ all jobs required → single ci-complete gate\n```\n\n| Problem | Tool | Mechanism |\n|---|---|---|\n| TypeScript dep linking | pnpm workspaces |\n`workspace:*` + symlinks |\n| Python dep linking | uv workspaces |\n`{ workspace = true }` + editable installs |\n| Go dep linking | go workspaces |\n`use` directives (gitignored) |\n| Cross-language task orchestration | Task (Taskfile.yml) |\n`includes` + `sources` /`generates` caching |\n| Cross-language contracts | buf + Protobuf | single `.proto` → 3 language stubs |\n| TypeScript lint + format | Biome | root `biome.json`\n|\n| Python lint + format | Ruff | root `pyproject.toml` `[tool.ruff]`\n|\n| Go lint + format | golangci-lint + gofumpt | root `.golangci.yml`\n|\n| Cross-language git hooks | lefthook |\n`glob_matcher: doublestar` + parallel jobs |\n| CI: only run changed languages | dorny/paths-filter | per-language path filters + job conditions |\n\nThe polyglot monorepo isn't one tool — it's a composition. Three workspace managers run in parallel. Task sits on top as the single command interface. buf provides the shared contract layer between all three languages. lefthook enforces quality at commit time regardless of which language a developer touches.\n\nThe complexity cost is real: you're maintaining three build toolchains instead of one. The payoff is equally real: one `git blame`\n\n, one pull request for a cross-language feature, one place to define the contract between your TypeScript frontend and your Go and Python backends.\n\nGo compiles to a static binary — Docker is trivial. TypeScript builds to a `dist/`\n\ndirectory — straightforward. Python is the tricky one: editable workspace installs point back at source paths and break inside a container without extra work.\n\nThe fix is `uv sync --package ml --no-editable`\n\n. The build context **must be the repo root** — not `services/ml/`\n\n— because `uv sync`\n\nneeds to resolve `libs/shared`\n\n, `gen/python`\n\n, and the root `pyproject.toml`\n\n.\n\nWithout a `.dockerignore`\n\n, Docker sends the entire monorepo as context including `node_modules/`\n\n, Go binaries, and the frontend build cache. Add this at the repo root:\n\n```\n# .dockerignore (at repo root — used by all Python service Dockerfiles)\nfrontend/\npackages/\nnode_modules/\n.turbo/\n.task/\n.next/\n*.go\ngo.*\ngo.work\ngo.work.sum\n.venv/\n```\n\nThen build from the repo root: `docker build -f services/ml/Dockerfile .`\n\n```\n# services/ml/Dockerfile\nFROM python:3.12-slim AS builder\nCOPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv\nWORKDIR /app\n\n# Copy manifests first — dep install layer is cached separately from source\nCOPY pyproject.toml uv.lock ./\nCOPY libs/shared/pyproject.toml  libs/shared/pyproject.toml\nCOPY gen/python/pyproject.toml   gen/python/pyproject.toml\nCOPY services/ml/pyproject.toml  services/ml/pyproject.toml\n\nRUN uv sync \\\n    --package ml \\      # install only ml + its deps (libs/shared, gen/python, etc.)\n    --no-dev \\          # exclude pytest, mypy, ruff\n    --frozen \\          # fail if uv.lock is stale\n    --no-editable       # copy source into site-packages — no source tree needed at runtime\n\n# Copy source after dep install (changes here don't bust dep cache)\nCOPY libs/shared/src  libs/shared/src\nCOPY gen/python        gen/python\nCOPY services/ml/src  services/ml/src\n\nFROM python:3.12-slim\nCOPY --from=builder /app/.venv /app/.venv\nENV PATH=\"/app/.venv/bin:$PATH\"\nCMD [\"uvicorn\", \"ml.main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8080\"]\n```\n\n** --no-editable is the critical flag.** Without it,\n\n`libs/shared`\n\ninstalls as an editable install pointing at `libs/shared/src/`\n\nin the build context. The final image has a `.venv`\n\nwith broken symlinks and imports fail at runtime. `--no-editable`\n\ncopies the package source into `.venv/lib/pythonX.Y/site-packages/`\n\n— self-contained.**Fresh clone requires task go:init** —\n\n`go.work`\n\nis gitignored; a fresh clone has no workspace file. Run `task go:init`\n\nonce per clone (or once per machine). The `status:`\n\nguard makes it idempotent — it's safe to run every time.** gen/ts/ must be a workspace member, not file:** — a\n\n`file:`\n\nreference copies the package into `node_modules`\n\nat install time. After `buf generate`\n\n, the copies are stale until `pnpm install`\n\nruns again. Use `workspace:*`\n\n(symlinked, always live).**Docker build context must be repo root** — `uv sync --package ml`\n\nresolves workspace deps (`libs/shared`\n\n, `gen/python`\n\n). Build with `docker build -f services/ml/Dockerfile .`\n\nfrom the repo root. Use `.dockerignore`\n\nto exclude `node_modules/`\n\n, Go source, and the frontend build cache.\n\n** GOWORK=off in CI must be explicit** —\n\n`.gitignore`\n\nprevents committing `go.work`\n\nbut doesn't prevent `git add -f`\n\n. Set `GOWORK=off`\n\nin every CI Go step. Don't rely on the file not being present.** go.mod replace directives are required for CI** —\n\n`go.work`\n\nresolves in-repo deps locally, but `GOWORK=off`\n\nrequires each module's `go.mod`\n\nto have `replace`\n\ndirectives: `replace github.com/your-org/beacon/gen/go => ../../gen/go`\n\n. Without these, CI fails because the module can't find its in-repo dependencies. Run `go mod tidy`\n\nin each module after adding them.**golangci-lint cannot accept {staged_files} across packages** — passing files from multiple packages produces \"named files must all be in one directory.\" Run golangci-lint per-module in pre-push, not per-file in pre-commit.\n\n**pnpm and uv both need lock enforcement in CI** — `pnpm install --frozen-lockfile`\n\nand `uv sync --frozen`\n\nare the equivalent safety checks. Omitting either means CI silently tests different dep versions than developers have locally.\n\n** sources lists must include lockfiles** — Task caches by hashing declared\n\n`sources`\n\n. If `uv.lock`\n\n, `pnpm-lock.yaml`\n\n, or `go.sum`\n\nchanges without any source file changing, Task serves a stale cached build. Add lockfiles to every `sources`\n\nlist.** task dev log visibility** — three servers writing to one terminal is unreadable. Run them in separate terminals or use a process manager (\n\n`overmind`\n\n, `foreman`\n\n, or `pnpm turbo run dev`\n\nfor TypeScript). `task ts:dev`\n\n, `task go:dev`\n\n, `task py:dev`\n\nas separate commands is the pragmatic answer.**uv members and pnpm coexistence** — uv will error if a\n\n`members`\n\nglob matches a directory that has no `pyproject.toml`\n\n. Be explicit: list Python-only directories rather than using broad globs like `services/*`\n\n.**mypy in pre-push, not pre-commit** — mypy is 10–30s on a real Python codebase. Every commit, on any `.py`\n\nchange. It belongs in `pre-push`\n\nalongside `buf breaking`\n\n, not blocking commits.\n\n** uv sync --frozen in CI** — without\n\n`--frozen`\n\n, uv silently re-resolves if `uv.lock`\n\nis stale, meaning CI may test different dep versions than developers have locally.** --no-editable in Python Docker images** — editable installs point at source paths in the build context. Use\n\n`uv sync --package ml --no-editable`\n\nin Dockerfiles so the `.venv`\n\nis self-contained.** jq is a prerequisite** —\n\n`Taskfile.go.yml`\n\nuses `go work edit -json | jq`\n\n. Install with `brew install jq`\n\nor `apt install jq`\n\n. Add to your `task prereqs`\n\ncheck or README.**proto changes cascade + deployment order** — `buf breaking`\n\ncatches compile-time breakage, but not runtime version skew. In production, always deploy consumers (Python ML, TypeScript frontend) before producers (Go API) when adding fields. Never remove or rename fields in a single deployment — deprecate first, remove in a later release.\n\n**lefthook glob_matcher: doublestar** — without this line,\n\n`**/*.py`\n\nwon't match files more than one directory deep. Always verify with `lefthook run pre-commit --all-files`\n\nafter first setup.**Generated code is committed** — `gen/go/`\n\n, `gen/python/`\n\n, `gen/ts/`\n\nare in git. This keeps CI simple and makes stub changes reviewable. Proto changes produce large diffs — that's the tradeoff.\n\n**Dependency updates across three package managers** — use [Renovate](https://docs.renovatebot.com/) (not Dependabot — Renovate handles pnpm, uv, and Go modules in a single config). For audits: `pnpm audit`\n\n, `uv audit`\n\n, `govulncheck ./...`\n\nper Go module.\n\n**Adding a new service** — checklist:\n\n`pyproject.toml`\n\n`members`\n\n, create `pyproject.toml`\n\n+ `Taskfile.yml`\n\n+ `dir:`\n\ninclude, add to `lefthook.yml`\n\npaths, add to CI `py`\n\npaths-filter`go.work use`\n\n, add to `Taskfile.go.yml init`\n\ncmd + lint/test, add CI matrix entry`pnpm-workspace.yaml`\n\n, add Taskfile include**Cross-language integration tests** — unit tests per language are not enough. Add a `task test:integration`\n\nthat starts all three services (via Docker Compose or k3s) and runs contract tests against the live stack. [Pact](https://pact.io) works across Go, Python, and TypeScript.\n\n**Environment config** — one `.env`\n\nat repo root, read by all three languages:\n\n`dotenv`\n\nor `@t3-oss/env-nextjs`\n\n`pydantic-settings`\n\nwith `env_file = \".env\"`\n\n`godotenv.Load()`\n\nor pass via Docker env**CODEOWNERS** — `proto/`\n\nshould require review from both Go and Python leads. Use GitHub CODEOWNERS to enforce this on every proto PR.\n\n**Hot reload across proto changes** — `task dev`\n\ndoesn't re-run `buf generate`\n\nwhen `.proto`\n\nfiles change. Add a file-watcher task (e.g., via `watchexec`\n\nor `task --watch`\n\n) that runs `buf generate`\n\nand restarts affected dev servers on `.proto`\n\nchanges.", "url": "https://wpnews.pro/news/polyglot-monorepo-magic-typescript-python-and-go-in-one-repo", "canonical_source": "https://dev.to/_mh/polyglot-monorepo-magic-typescript-python-and-go-in-one-repo-298n", "published_at": "2026-06-06 07:22:00+00:00", "updated_at": "2026-06-06 07:41:44.592738+00:00", "lang": "en", "topics": ["ai-infrastructure", "mlops"], "entities": ["TypeScript", "Python", "Go", "Next.js", "FastAPI", "Celery", "Protobuf", "Biome"], "alternates": {"html": "https://wpnews.pro/news/polyglot-monorepo-magic-typescript-python-and-go-in-one-repo", "markdown": "https://wpnews.pro/news/polyglot-monorepo-magic-typescript-python-and-go-in-one-repo.md", "text": "https://wpnews.pro/news/polyglot-monorepo-magic-typescript-python-and-go-in-one-repo.txt", "jsonld": "https://wpnews.pro/news/polyglot-monorepo-magic-typescript-python-and-go-in-one-repo.jsonld"}}