Why Module Federation doesn't fit backend services

Module Federation solves a browser problem. A single-page application ships as one bundle, so a team that wants to release its part of the UI on its own schedule would normally have to wait for the whole app to be rebuilt and redeployed. Federation breaks that link. A host application loads "remote" bundles at runtime from separately deployed locations and negotiates shared dependencies, such as a single copy of a UI framework, so the pieces can be composed in the user's browser.

Backend services don't have this problem. Each service is already its own deployable: its own process, container image and release cadence. Services compose over the network through HTTP, gRPC or message queues, not by loading each other's code into one runtime. Loading remote modules into a Node process at runtime would add versioning, trust and failure modes to a system that already deploys independently. It would not give you anything you lack.

When a backend team reaches for federation, the real complaint is usually build time and CI cost. A change to one shared library triggers a full TypeScript compile of every service that imports it. Pipelines rebuild and retest code that didn't change, and the bill grows as the repo grows.

This article is for backend and platform engineers running Node/TypeScript services in an Nx monorepo whose builds and pipelines have become slow. The aim is to split and skip build work with the tools Nx already provides, without federating anything at runtime.

Non-buildable, buildable and publishable libraries: what each mode changes

Nx gives every library one of three build modes, and the choice decides who compiles the code and what metadata has to exist.

A non-buildable library has no build target. The app imports it through a TypeScript path mapping in the root tsconfig, and the app's bundler compiles the library source along with its own code. This mode has the least ceremony. The cost is that every app build recompiles every library it touches.

A buildable library has its own build target, typically a tsc-based executor, which writes compiled output to a dist folder. Inside the workspace, that output can be built once, cached, and reused by any consumer. The library still never leaves the repo, so its version number is irrelevant.

A publishable library is a buildable library plus everything a registry needs: a valid package name as its import path, a meaningful version, and accurate dependencies and peerDependencies. Once a package is published, every one of those fields is a contract with consumers outside the repo.

Aspect Non-buildable Buildable Publishable
Who compiles it App's bundler Its own build target Its own build target
package.json Not required Often generated, internal only Required, must be correct
Versioning None Not meaningful Semver, enforced by consumers
Peer dependencies N/A Rarely matter Must be declared accurately
Release ceremony None None Version bumps, changelogs, publishing

Making a library publishable when nothing outside the repo consumes it adds overhead with no benefit. You end up maintaining versions and dependency declarations that no one reads, and any drift in them can still break the build.

How Nx incremental builds work: dependsOn, hashing and consuming prebuilt outputs

The mechanism has three parts, and the savings depend on all three working together.

First, the task graph. The app's build target declares dependsOn with a ^build entry. The caret means "run build on every project this one depends on, first." Nx walks the project graph and schedules library builds ahead of the app, running independent libraries in parallel.

Second, hashing. Before running any library build, Nx computes a hash from that target's inputs: source files, relevant config, the hashes of its own dependencies, and whatever else the inputs definition includes. If the hash matches a previous run, Nx restores the declared outputs from cache instead of compiling. A change to one library invalidates that library and everything downstream of it. Unrelated libraries are not affected.

Third, consumption. The app's compiler or bundler must resolve each library to its built output, not its source. If TypeScript path mappings still point at src, the app recompiles every library from scratch. The library builds then run, get cached, and are ignored. Some executors expose options that control this behavior. The names and defaults have changed across releases, so check them against the documentation for your installed Nx version.

Resolving to built output has a side effect. Source imports in local dev and built outputs in CI can behave differently. Decorator handling, emitted type metadata used by dependency-injection frameworks, and ESM/CJS interop all depend on how the code was compiled. Run your tests against the same mode you ship.

{
  "namedInputs": {
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": ["default", "!{projectRoot}/**/*.spec.ts"]
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "outputs": ["{options.outputPath}"],
      "cache": true
    }
  }
}

A minimal nx.json where every build runs its dependencies' builds first (^build) and is cached under a hash of its own and its dependencies' production inputs, so changing a spec file never invalidates a build.

Caching and affected first: the bigger lever for nx build performance

Before converting libraries to buildable mode, measure what caching and affected already give you. Nx hashes each task's inputs. If the hash matches a stored entry, Nx replays the terminal output and restores the files instead of running the task. A service that imports twenty non-buildable libraries still gets a cache hit when none of them changed. The gap that buildable libraries close is narrower: a change to one library forces a recompile of the whole service bundle. If most commits touch only a few projects, nx affected -t build against your base branch already skips the services that commit could not have changed.

Look at the graph before you restructure anything. nx graph shows project dependencies, and running it with --affected highlights what a given change touches. If one shared utils or config library sits under every service, splitting builds won't help much. Breaking up that hub will.

A remote cache extends hits across machines, and it also creates a trust problem. Anyone who can write to the cache can store an artifact under a valid hash, and CI may then deploy it unexamined. That is cache poisoning. Developer laptops should have read-only credentials. Only CI, building from protected branches, should write. Also check what lands in your outputs. An environment variable inlined at build time, or a .env file copied into dist, is stored and served to every reader of the cache.

#!/usr/bin/env bash
# Run from the workspace root, before making any library buildable.
set -euo pipefail

BASE=origin/main   # the branch your PRs merge into
HEAD=HEAD

# 1. Find the hubs. Open the full project graph in the browser and look for
#    libraries that every service depends on (utils, config, shared types).
npx nx graph

# 2. See what this change touches. This highlights affected projects in the graph.
npx nx graph --affected --base=$BASE --head=$HEAD

# 3. List affected projects as plain text, for diffs or CI logs.
npx nx show projects --affected --base=$BASE --head=$HEAD

# 4. Build only affected projects, twice. On the second run every task should
#    be a cache hit. If one is not, an input such as a timestamp or an
#    unpinned file is changing the hash.
npx nx affected -t build --base=$BASE --head=$HEAD
npx nx affected -t build --base=$BASE --head=$HEAD

# 5. Check outputs before they reach a shared cache. Anything in dist/ is stored
#    and served to every reader of the remote cache.
if find dist -name '.env*' -print | grep -q .; then
  echo 'ERROR: .env file found in build outputs' >&2
  exit 1
fi

# Crude check for inlined values. Adjust the patterns to your secret formats.
if grep -rEl '(SECRET|PRIVATE_KEY|PASSWORD)["'\'' ]*[:=]' dist; then
  echo 'ERROR: possible secret inlined into build output' >&2
  exit 1
fi

echo 'Outputs look clean.'

Commands that show the project graph, list and build only affected projects, confirm cache hits on a repeat run, and check build outputs for .env files or inlined secrets before a remote cache stores them.

Configuring inputs and outputs so cache keys stay stable

Cache keys come from inputs, so they deserve the same review as the build itself. A common pattern defines two named inputs in nx.json. The default input covers every file under {projectRoot}. The production input starts from default and negates what never reaches the compiled artifact, using ! patterns for *.spec.ts, test setup files, tsconfig.spec.json, fixtures and *.md. The build target then uses production for its own project and ^production for its dependencies. As a result, editing a library's README or adding a spec no longer changes the hash of every service that imports it.

There are two ways to get this wrong, and they are not equally bad. Inputs that are too broad cause cache misses that look random, which wastes time but never ships broken code. Inputs that are too narrow are a correctness bug. If a JSON schema, a SQL migration bundled as an asset, or a tsconfig the compiler reads sits outside production, Nx restores an output built from the old file and reports success. When you are unsure whether a file affects the build, include it.

Declare outputs explicitly, for example {options.outputPath}, so Nx restores exactly the directory the executor writes to.

Most "works in dev, fails in build" bugs come from drift between three places. The first is tsconfig.base.json path aliases, which resolve to source during serve. The second is the per-library tsconfig.lib.json, which can differ in rootDir or module settings. The third is output paths, which a buildable consumer resolves to the compiled dist instead of source. When these three disagree, an import that works in dev can fail in the production build.

// nx.json (Nx parses this file with comments allowed)
{
  "namedInputs": {
    // Workspace-wide files that affect every compile, e.g. path aliases
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
    // Everything in the project: safe but broad
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    // Only what can reach the compiled artifact. tsconfig.lib.json stays in.
    "production": [
      "default",
      "!{projectRoot}/**/?(*.)+(spec|test).ts",
      "!{projectRoot}/src/test-setup.ts",
      "!{projectRoot}/tsconfig.spec.json",
      "!{projectRoot}/jest.config.ts",
      "!{projectRoot}/**/__fixtures__/**",
      // Only safe if no .md file is shipped as an asset
      "!{projectRoot}/**/*.md"
    ]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "dependsOn": ["^build"],
      // Own production files plus dependencies' production files.
      // A README or spec edit in a lib no longer rehashes its consumers.
      "inputs": ["production", "^production"],
      // Must match where the executor writes, or the cache restores the wrong dir.
      // Keep outputPath consistent with tsconfig.lib.json outDir/rootDir.
      "outputs": ["{options.outputPath}"]
    }
  }
}

An nx.json that defines a production named input excluding specs, test config, fixtures and docs, and wires it into the build target with explicit outputs so cache keys change only when shipped code changes.

When buildable libraries pay off and when to avoid them

The decision comes down to two factors. The first is how often a library changes. The second is how many other projects sit downstream of it. Good candidates are large, slow-changing, widely shared libraries such as domain models, database clients and generated API clients. Each one is expensive to compile and rarely edited, so its cached output gets reused across many PRs.

Avoid making small, frequently edited leaf libraries buildable. Each buildable library becomes its own task, and every task pays a fixed cost for process spawn, type-checker setup and hashing. For a small library, that fixed cost can be larger than the compile itself. Tightly interconnected libraries are also poor candidates: one edit invalidates the whole cluster, so you pay overhead on every task and save almost nothing.

The table below is an illustrative estimate. It assumes 20 libraries, 2 changed per PR, a 90s full compile, about 4s per small library (overhead included) and about 15s of app linking.

Scenario Libraries rebuilt Estimated time Compared with 90s full compile
Two leaf libraries changed 2 2 × 4 + 15 = 23s Wins
Shared model changed, 15 dependents 15 15 × 4 + 15 = 75s Marginal
Cold cache (fresh CI runner) 20 20 × 4 + 15 = 95s Loses

Incremental builds only pay off when the cache hit rate is high and edits stay near the leaves of the graph.

There is also a structural tension. A boundary chosen for cache efficiency, such as "everything that rarely changes," often cuts across domain concepts or team ownership. Draw boundaries for domain clarity first. Then make a library buildable only where that boundary also happens to isolate slow, stable code.

Keeping boundaries intact and shipping the built service

Tag every project (for example scope:billing and type:data-access) and turn on the @nx/enforce-module-boundaries lint rule with depConstraints that say which tags may depend on which. The rule also flags circular dependencies and deep imports, and both break incremental builds:

  • A cycle leaves Nx with no valid order for dependsOn: ["^build"], so the libraries cannot be built one after another.
  • A deep import such as @org/billing/src/lib/internal skips the library's public entry point. The consumer then compiles that source file directly instead of using the prebuilt output, so the cache saves nothing and changes to internals leak into code that should not see them.

Run the lint rule in CI as a required check. If it is only a warning, these imports pile up unnoticed.

Shipping the artifact

Building libraries separately does not change what you deploy. The service still has to bundle the built libraries or resolve them at runtime. Enable the build executor's option to generate a package.json for the app. It lists only the third-party packages the service actually imports, so the Docker image installs that set and not the whole workspace's dependencies. Mark native modules such as bcrypt as external rather than bundling them. Then install them inside the image's final stage, on the same OS and CPU architecture that will run it, because native binaries compiled on a developer laptop often fail in the container.

Alternatives and cost

If buildable libraries don't pay off, you have three options:

  • Single compile: compile each service once and rely on caching and affected.
  • Publishable packages: use them when another repository consumes the code.
  • Separate repositories: split when teams truly deploy and version independently.

Buildable libraries trade more configuration and boundary discipline for shorter rebuilds. That trade lowers engineering time and CI cost only when the libraries are stable and the boundaries are enforced.

{
  "root": true,
  "plugins": ["@nx"],
  "overrides": [
    {
      "files": ["*.ts"],
      "rules": {
        "@nx/enforce-module-boundaries": [
          "error",
          {
            "enforceBuildableLibDependency": true,
            "allow": [],
            "depConstraints": [
              {
                "sourceTag": "scope:billing",
                "onlyDependOnLibsWithTags": ["scope:billing", "scope:shared"]
              },
              {
                "sourceTag": "type:app",
                "onlyDependOnLibsWithTags": ["type:feature", "type:data-access", "type:util"]
              },
              {
                "sourceTag": "type:data-access",
                "onlyDependOnLibsWithTags": ["type:data-access", "type:util"]
              },
              {
                "sourceTag": "type:util",
                "onlyDependOnLibsWithTags": ["type:util"]
              }
            ]
          }
        ]
      }
    }
  ]
}

A root .eslintrc.json that sets @nx/enforce-module-boundaries to error, so cycles, deep imports and tag violations fail CI, and that limits which scope and type tags each project may depend on.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nx buildable libraries, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.