Goatfied

architecture

Lsp Routing Strategies For Monorepos Design Tradeoffs: practical systems guide

Technical field guide on lsp routing strategies for monorepos design tradeoffs for teams building dependable AI coding workflows.

2026-07-208 min readBy Goatfied
Engineering team collaborating on ai editor architecture

Your monorepo has 40 services, each with its own TypeScript configuration and language server instance spinning up whenever you open a file. Jump-to-definition takes three seconds. Your editor's fan sounds like a data center. You've got a routing problem.

Most teams discover LSP routing the hard way: after an AI agent or junior engineer makes a "small" change that breaks six downstream services because the language server only saw one package's context. Or when their editor crashes opening a file because twelve LSP instances are fighting over 16GB of RAM.

The fundamental tension is between isolation and awareness. Route each package to its own language server and you get fast, focused responses—but no cross-package intelligence. Route everything to one massive server and you get complete context—but glacial performance and a single point of failure.

The three core routing patterns

**Per-package isolation** spawns a separate LSP server for each buildable unit. Open `packages/api/src/handler.ts` and you get a tsserver instance that only knows about the `api` package. Jump to `@myorg/utils` and you're actually looking at the type definition file, not the source, because that package is external to this server's view.

This works well when packages genuinely are independent services that talk over HTTP. It breaks down when you have tight library coupling—imagine a design system where `@myorg/components` depends on `@myorg/tokens` and `@myorg/theme`. Every refactor requires manually updating three language servers or restarting the editor.

**Project-wide routing** points every file at one LSP server with the entire workspace in scope. This is what you get with a root `tsconfig.json` that references all packages. Rename a function in your utils library and every import across 40 services updates correctly.

The cost is memory and CPU. A 2M LOC TypeScript monorepo can consume 8-12GB just for the language server. Initial project load takes 30-90 seconds. And if that server crashes or hangs, your entire editor loses intelligence until restart.

**Hybrid with explicit boundaries** uses per-package servers but configures them to share specific cross-cutting concerns. Your root `tsconfig.json` defines shared compiler options and path mappings, but each package's `tsconfig.json` only references immediate dependencies. The language server for `api` knows about `@myorg/utils` and `@myorg/auth`, but not about `@myorg/analytics` or 35 other packages it never imports.

This is the most maintainable pattern for large polyglot repos. The tradeoff is configuration complexity—you must keep dependency graphs in sync between your build tool (Bazel, Nx, Turborepo) and your LSP routing.

When naive routing causes real production incidents

An AI coding agent at a client with 120 services was configured to use project-wide routing. During a refactor to move authentication middleware, the agent's language server saw the new location immediately and updated all imports. But the CI pipeline ran per-package TypeScript checks with isolated LSP contexts—the same pattern human developers used locally for performance.

The agent's PR passed its self-checks (compile, lint, test all green) because its wide-context LSP said the imports were valid. But CI failed in 18 packages that the agent never opened. The imports existed and technically resolved in the full-workspace tsconfig, but those packages' isolated LSP views couldn't find the moved module.

The fix required explicit dependency declarations in each affected package. The lesson: your agent's LSP routing must match the routing that gates production deploys. At Goatfied, we enforce this by running validation steps in the same constrained LSP environment your CI uses, not just the agent's editing context. The agent loop's "validate" gate catches this class of error before proposing changes.

Routing for incremental adoption

If you're moving from a monolith to monorepo, you'll hit an awkward middle state: hundreds of files still in a shared `src/` directory while new packages live under `packages/`. Per-package routing doesn't work because half your code isn't packaged yet. Project-wide routing is too slow.

The answer is staged namespacing. Create a pseudo-package for the legacy monolith with its own `tsconfig.json`:


{

  "extends": "../../tsconfig.base.json",

  "include": ["src/**/*"],

  "exclude": ["../packages/**/*"],

  "compilerOptions": {

    "composite": true,

    "outDir": "dist"

  }

}

Route the monolith files to one LSP instance. New packages get their own. As you extract code, you update the monolith's exclude list and add explicit references to the extracted packages. The monolith's LSP shrinks over time while packaged code gets isolated intelligence.

This also lets you enforce boundaries: the monolith can import from packages, but packages cannot import from the monolith. Configure this at the language server level and auto-imports won't even suggest the legacy code.

Memory budgets and process pinning

On a 32GB laptop, you can run maybe four heavyweight TypeScript LSP servers before swapping. The naive solution is "just add more RAM." The engineering solution is process pinning.

Pin high-churn packages (your API gateway, shared component library) to persistent LSP servers that warm up at editor start and stay resident. Route low-churn packages (internal tools, one-off scripts) to on-demand servers that spawn when you open a file and die after five minutes of inactivity.

For polyglot repos, this gets more complex. Your Go services might share one `gopls` instance. Your Rust crates each need `rust-analyzer` because compile times benefit from isolated cargo workspaces. Python packages can share a Pyright server if you use a monorepo tool that handles editable installs correctly.

The heuristic: if opening a file should trigger a full dependency graph analysis (Python, JavaScript), use shared routing. If the language has explicit module boundaries and compile-time linking (Rust, Go), use isolated routing.

What Goatfied's agent loop does differently

We run LSP routing in two phases. During the plan step, the agent gets project-wide context to understand dependencies and choose the right files to edit. But during the constrain and edit steps, we narrow to per-package routing—the same view your CI and local dev environment use.

This catches the class of errors where "it compiles in the agent's context but not in yours." The validate gate runs TSC, ESLint, and your test suite with the exact LSP configuration that production uses. If a change would break someone else's isolated view of the codebase, we catch it before proposing the diff.

You can self-host Goatfied and configure LSP routing per-team, per-repo, or per-package. The default is hybrid-with-boundaries because it matches how most engineering orgs actually structure their build systems.

  • [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)

Related posts

Lsp Routing Strategies For Monorepos Design Tradeoffs: practical systems guide | Goatfied Blog