architecture
Lsp Routing Strategies For Monorepos Production Playbook: practical systems guide
Technical field guide on lsp routing strategies for monorepos production playbook for teams building dependable AI coding workflows.
When your monorepo hits 100+ packages and multiple language servers, developers start reporting slowdowns, duplicate symbol definitions, and mystery crashes from editors trying to index everything at once. The problem isn't the LSP protocol—it's that most editors naively spawn one language server per workspace root and let it crawl every subdirectory.
Production monorepos need deliberate routing: which LSP instances run where, what they index, and how file events propagate. This isn't premature optimization. It's the difference between sub-second completions and 30-second hangs every time someone opens a TypeScript file.
The routing problem: why single-server setups break down
A typical monorepo might contain a Go backend in `/services/api`, a React frontend in `/web/app`, shared Protobuf definitions in `/proto`, and infrastructure code in `/infra/terraform`. If you start `gopls`, `typescript-language-server`, `bufls`, and `terraform-ls` with the workspace root as their only path, each will try to index the entire tree.
The immediate symptom is memory bloat—four language servers each holding gigabytes of unused AST data. The worse symptom shows up during builds: when your CI system generates code or updates `package.json`, file watchers fire across all four servers. Each one tries to re-index, even though only one cares about the changed file. You get cascading invalidation storms and race conditions where the TypeScript server tries to parse a half-written Go file.
Multi-root workspaces: the standard escape hatch
LSP's multi-root workspace feature lets you tell a language server exactly which directories matter. Instead of passing `/home/user/monorepo` as a single root, you pass:
{
"workspaceFolders": [
{ "uri": "file:///home/user/monorepo/services/api", "name": "api" },
{ "uri": "file:///home/user/monorepo/services/worker", "name": "worker" }
]
}
Now `gopls` indexes only those two trees. It ignores `/web`, `/proto`, and everything else. File watchers become scoped. Memory usage drops by 60-80% in typical layouts.
The trade-off: you lose cross-boundary features. If your Go code imports a generated Protobuf package built from `/proto`, and you haven't included `/proto` in the workspace folders, gopls can't jump to the `.proto` definition. You get completions from the generated Go stubs, but no source-level navigation.
Strategy one: strict boundaries with build artifacts
For teams that treat package boundaries seriously, the cleanest approach is to exclude upstream sources from downstream language servers. Your API service depends on generated Protobuf code, but the language server only sees the committed generated files in `/services/api/internal/genproto`.
This works when:
- You commit generated code or fetch it from a package registry during setup
- Each package has a well-defined public API (exported types, documented interfaces)
- Cross-boundary refactors are rare and use separate tooling (search-and-replace scripts, codemod pipelines)
In practice, this is how Google's internal tooling works. Developers rarely jump from a caller in one service to the implementation in another during normal coding. They use separate review tools and codesearch for cross-boundary exploration.
Strategy two: dynamic root expansion with proximity heuristics
For teams that do frequent cross-boundary work, you can teach the editor to expand workspace roots on demand. When a developer opens `/services/api/main.go` and that file imports `monorepo/proto/users/v1`, the editor detects the missing root and adds `/proto` to the gopls workspace.
The heuristic: if a file imports a package whose path prefix matches a sibling directory in the monorepo, add that directory to the active workspace. This requires editor-side logic—usually a plugin or extension—that watches import statements and calls the LSP `workspace/didChangeWorkspaceFolders` method.
The downside is complexity. You need rules for when to collapse roots (after N minutes of inactivity?), how to handle circular dependencies (API imports proto, proto imports API types?), and what to do when someone opens a rarely-touched file that imports fifteen different packages. Some teams set a maximum root count (say, five) and evict the least-recently-used when you hit the limit.
Strategy three: federated LSP with path routing
A more architectural approach: run multiple language server instances for the same language, each responsible for a different subtree. A routing layer intercepts LSP requests, examines the file path, and forwards to the appropriate instance.
Editor
↓
Routing Proxy
├→ gopls (services/api, services/worker)
├→ gopls (infra/terragrunt)
└→ gopls (tools/cli)
The proxy maintains a path→instance mapping. When the editor sends `textDocument/definition` for a symbol in `/services/api/handler.go`, the proxy routes it to the first gopls instance. If that symbol resolves to a file outside the instance's roots, the proxy can forward a secondary request to another instance or return a fallback "definition not available."
This is overkill for most teams, but essential at the scale where you have dozens of independently deployable services with separate build graphs. You can even restart individual instances during deploys without editor disruption.
Handling file watches across boundaries
Regardless of routing strategy, you must prevent file-watcher amplification. When a build writes 50 generated files in `/proto/gen`, you don't want every language server receiving 50 `workspace/didChangeWatchedFiles` events.
The solution is scoped watchers. Tell each language server to watch only its workspace roots plus a small set of explicit paths:
{
"watchers": [
{ "globPattern": "/services/api/**/*.go" },
{ "globPattern": "/proto/gen/**/*.pb.go" }
]
}
If your routing proxy sits in front, it can deduplicate events: when `/proto/gen/users.pb.go` changes, notify only the gopls instances whose roots include files that import that package. The proxy tracks import graphs by periodically scraping module files or listening to LSP symbol requests.
Goatfied's approach: small diffs plus compile gates
In Goatfied, the agent loop (plan → constrain → edit → validate → retry) naturally limits the blast radius of LSP activity. Because each edit is a small, reversible diff validated by compile/lint/test steps, we scope language server roots to the packages touched in the current plan. If the agent edits a single service, only that service's LSP runs.
The validation phase uses the same routing logic: compile checks run in the minimal workspace, not the full monorepo. This means faster feedback and less risk of cascading invalidation when unrelated packages change. When the agent moves to a new task, we tear down the previous LSP instances and spin up fresh ones for the new scope.
This matches the "strict boundaries with build artifacts" strategy for production stability, but we inject dynamic expansion during the validate phase if the agent needs cross-package context for error diagnosis.
Related reading
- [Goatfied vs Cursor vs GitHub Copilot: a benchmark across 50 real PR tasks](/blog/goatfied-vs-cursor-vs-github-copilot-benchmark-50-pr-tasks)
- [The Goatfied agent loop: how we ship code that actually compiles first try](/blog/goatfied-agent-loop-compiles-first-try)
