Why Module Federation is the wrong tool for most backends

Module Federation solves a specific problem: a host application loads code at runtime that was built and deployed independently. In its original setting, the browser fetches a remote bundle on demand, wires it into the running page, and negotiates shared dependencies so two teams don't ship two copies of the same framework. The value comes from late binding. The host does not know the exact version of the remote until it runs.

Backends rarely want that. A Node API process starts, loads its code, and serves requests until it is replaced. Loading code at runtime from another deployment adds failure modes that don't exist in a normal backend. A remote can be unavailable at boot. Its version can drift from what the host was tested against. The server keeps running while the code inside it changes. You give up the property that makes a backend deploy easy to reason about: the artifact you tested is the artifact you run.

What backend teams need is one of three simpler boundaries:

  • Build-time composition. Shared libraries compiled into each app, so a change is checked by the type checker and the tests.
  • A process boundary. Separate deployable apps, such as an API and a worker, built from the same workspace.
  • A network boundary. Separate services, used only where independent scaling or release cadence justifies the operational cost.

The real goals are faster CI, clearer ownership, and independent deploys where they pay for themselves. This guide is for backend and platform engineers running a large Nx-managed API app who want those goals without runtime code loading.

Who should use this approach

Library extraction fits a specific situation. Use it if most of the following describe your codebase:

  • One large backend app in an Nx workspace, or a codebase you are willing to move into one. The techniques here depend on the Nx project graph, so they do little for an app Nx sees as a single project.
  • Several teams or business domains in the same app. The main payoff is ownership: each domain gets libraries a team can own, review, and test on its own.
  • CI that reruns everything on every pull request. If the whole suite runs no matter what changed, and that run takes long enough to slow reviews, smaller projects let Nx skip work.
  • Domains that are mostly separable at the code and data level. Some cross-domain calls are fine. A single transaction spanning every table is not (see "When to avoid it").
  • A need for one or two extra deployables, such as a worker next to the API, without moving to a fleet of services.

It is a poor fit for a small app owned by one team, a suite that already finishes in a few minutes, or a codebase scheduled for replacement. Teams whose main problem is runtime scaling or independent release schedules will still benefit from libraries as a first step. They should expect to go further up the ladder below.

The decision ladder: from libraries to separate services

Treat the options as a ladder. Each rung costs more than the one below it, so climb only when the current rung stops solving a problem you actually have.

Step What it buys What it costs Stop here when
1. Extract libraries inside one app Per-library test and lint caching; affected runs skip untouched code Refactoring time, more project.json files CI is acceptable and ownership is clear
2. Enforce boundaries with tags Dependency rules that fail lint instead of relying on review Tag design, occasional rule exceptions Domains stay decoupled and one deploy is fine
3. Multiple deployable apps sharing libraries Separate deploy cadence and runtime profile (API vs worker) Extra pipelines and config; shared libs still couple release timing Most teams settle here
4. Network-separated services Independent scaling, team-owned release cycles Latency, partial failure, tracing, contract tests, versioned APIs Only when scaling or autonomy demands it

Most teams should stop at step 2 or 3. Steps 1 through 3 keep every call in-process, so a function signature change is caught at compile time and a failure surfaces as a stack trace rather than a timeout.

Step 4 is a different kind of move. It does not make builds faster, because step 3 already gives you separate build targets. What it changes is runtime: each in-process call becomes a network hop that can be slow, fail partway through, or reach a service running an older version. Handling that requires retries, distributed tracing, and contract tests between services. Take on that work when one domain must scale independently or a team needs its own release cycle, and not for CI speed.

How it works: the project graph, affected detection, and caching

Every benefit on steps 1 to 3 comes from three Nx mechanisms. Knowing how they work explains why the extraction details later in this guide matter.

The project graph. Nx treats each app and library as a project, defined by its configuration (for example a project.json). It then builds a graph of which projects depend on which. Most edges come from static analysis of source imports: when a file in orders/feature-api imports @org/orders/data-access, Nx records that edge. A path alias maps that import to the library's directory. A single large app is one node in this graph, so Nx can't distinguish a change to billing from a change to orders. Extracting libraries creates the nodes and edges that the other two mechanisms use.

Affected detection. Given a base and head commit, Nx lists the changed files and maps each to the project that contains it. It then walks the graph to add every project that depends on a changed project. nx affected -t <target> runs the target only for that set. Files that sit outside any project but are declared as inputs for every project, such as the lockfile or the base TypeScript config, mark everything as affected.

The computation cache. Before running a task, Nx computes a hash from the task's inputs. These include the project's source files, the relevant configuration, the hashes of its dependencies, and the command itself. If a task with the same hash has already run, Nx restores its terminal output and artifacts instead of running it again. The cache is local by default. A shared remote cache can be configured so CI machines and developers reuse each other's results.

Together these mechanisms decide how much work a pull request triggers: changed projects plus their dependents, minus anything already cached. A narrow graph with clear edges keeps that set small. A graph where everything depends on one hub library brings it back to the full workspace. Boundary rules (step 2) keep the graph narrow over time. Multiple apps (step 3) are additional nodes that share library nodes, which is why a change to a shared library affects both apps.

Finding domain seams in a large API app

Before choosing any rung on that ladder, you need to know where the seams are. The examples in this guide use one hypothetical monolith: a single API app with orders, billing, and users domains, built over several years by a few teams.

Start by grouping files by business domain, not by technical layer. A controllers/ folder that holds every route hides ownership. Ask instead which domain each route, handler, and repository serves. Routes are usually easy to assign. Data access is not, because repositories tend to be shared between domains for convenience.

Next, list the cross-domain imports. Search each domain's files for imports that reach into another domain's internals. In our monolith, the order handler calls the billing service directly to charge a card, and billing imports the user repository to look up a billing address. Each of these imports is a dependency you will have to formalize or remove.

Shared tables are the hardest coupling to undo. Code can be moved in an afternoon. A table that two domains read and write needs a migration, a backfill, and agreement on which domain owns each column. If you split code into libraries, and later into services, while both still write to users, you get a distributed monolith: separate deployables that still have to release together.

Seam Coupling type Difficulty to undo
orders → billing charge call Direct function import Low: replace with an interface
billing → users repository Shared data access code Medium: expose a read API
orders and billing both write users.address Shared table High: assign ownership, migrate data

Nx library extraction: layers, entry points, and incremental moves

Extract one domain at a time, and give each domain a small set of layered libraries rather than one large one. A common shape is orders/feature-api (controllers, route handlers), orders/data-access (repositories, queries), and orders/util (pure functions, domain types). The layers matter because they let you express rules later, such as "data-access never imports feature code." Create them with nx generate, using the library generator from the plugin your workspace already uses (for plain TypeScript, @nx/js:library). Generators keep project configuration and path aliases consistent, which matters once you have dozens of libraries.

Move code incrementally. Extract a single module, re-export what callers need from the library's index.ts, update imports, and merge. The index file is the public API. Treat any deep import like @org/orders/data-access/src/lib/repo as a bug. Deep imports tie consumers to file layout and defeat the point of the split.

Put shared concerns (config loading, logging, auth middleware, database client setup) in small, separate infra or util libraries. Keep domain types out of a catch-all shared library. That library pulls in everything, ends up depending on everything, and invalidates the build cache whenever anyone touches it.

Every library adds project configuration, a tsconfig, and lint setup. If you split too finely, you spend more time maintaining configuration than you save in builds. Start coarse and split further only where the dependency graph shows a real seam.

Old and new structures will coexist for months. Assign a named owner and a tracked list of remaining modules, or the migration will stall halfway.

// libs/orders/util/src/index.ts
// Pure domain types and helpers. No framework or database imports here.
export type { Order, OrderStatus } from './lib/order';
export { isCancellable } from './lib/order-rules';

// libs/orders/data-access/src/index.ts
// The public API of @org/orders/data-access. Anything not exported
// here is an internal detail and can be moved or renamed freely.
export { OrderRepository } from './lib/order-repository';
export type { OrderQuery } from './lib/order-query';
// Not exported on purpose: './lib/sql-builders', './lib/row-mappers'

// libs/orders/feature-api/src/lib/orders.controller.ts
// Feature code depends on data-access and util. The reverse is never allowed.
import { OrderRepository } from '@org/orders/data-access';
import { isCancellable, type Order } from '@org/orders/util';
import { logger } from '@org/infra/logging'; // small shared infra lib, not a catch-all 'shared'

// Treat this as a bug. It couples the caller to the library's file layout:
// import { buildOrderSql } from '@org/orders/data-access/src/lib/sql-builders';

export class OrdersController {
  constructor(private readonly repo: OrderRepository) {}

  async cancel(id: string): Promise<Order> {
    const order = await this.repo.findById(id);
    if (!isCancellable(order)) {
      logger.warn({ orderId: id }, 'cancel rejected');
      throw new Error(`Order ${id} cannot be cancelled`);
    }
    return this.repo.updateStatus(id, 'cancelled');
  }
}

Layered libraries for the orders domain, where each library's index.ts is its narrow public API, feature code imports only through path aliases, and deep imports into src/lib are treated as bugs.

Enforcing boundaries with tags and the module boundary lint rule

Extracted libraries only stay separate if something stops engineers from importing across them. Nx handles this with project tags and the @nx/enforce-module-boundaries ESLint rule.

Give every project two tags: one for domain and one for layer. Domain tags look like scope:billing, scope:orders, or scope:shared. Layer tags look like type:feature, type:data-access, or type:util. Tags live in each project's configuration. They cost nothing at runtime.

In the rule's depConstraints, each entry matches a sourceTag and lists what it may use through onlyDependOnLibsWithTags. Domain constraints stop scope:billing from reaching into scope:orders and allow it only itself and scope:shared. Layer constraints stop a type:util library from pulling in data access. Nx applies every constraint that matches a project's tags, so the domain and layer rules combine. The same rule also flags relative imports that cross project roots and deep imports that bypass a library's public entry point.

To fail CI on violations, run lint as a required check, for example with nx affected -t lint. If the check is advisory, people will add exceptions under deadline pressure, and the boundaries will erode.

The rule has a blind spot. It reads static imports to build the project graph. A computed require(), a DI container that resolves providers by string token, or reflection-based wiring creates real runtime coupling that the graph never shows. Keep those mechanisms inside a single project, or document the dependency and declare it explicitly so the graph reflects it.

// eslint.config.js (workspace root)
const nx = require('@nx/eslint-plugin');

module.exports = [
  { plugins: { '@nx': nx } },
  {
    files: ['**/*.ts', '**/*.js'],
    rules: {
      // 'error' makes violations fail the lint target, so CI fails too
      '@nx/enforce-module-boundaries': ['error', {
        enforceBuildableLibDependency: true,
        allow: [],
        depConstraints: [
          // Domain: each scope may use only itself and shared code
          { sourceTag: 'scope:billing',
            onlyDependOnLibsWithTags: ['scope:billing', 'scope:shared'] },
          { sourceTag: 'scope:orders',
            onlyDependOnLibsWithTags: ['scope:orders', 'scope:shared'] },
          { sourceTag: 'scope:shared',
            onlyDependOnLibsWithTags: ['scope:shared'] },
          // Layer: dependencies point downward only
          { sourceTag: 'type:feature',
            onlyDependOnLibsWithTags: ['type:feature', 'type:data-access', 'type:util'] },
          { sourceTag: 'type:data-access',
            onlyDependOnLibsWithTags: ['type:data-access', 'type:util'] },
          { sourceTag: 'type:util',
            onlyDependOnLibsWithTags: ['type:util'] },
        ],
      }],
    },
  },
];

// Each project declares its tags in project.json, e.g.:
//   "tags": ["scope:billing", "type:data-access"]
// CI runs it as a required check:
//   npx nx affected -t lint
// Not covered: require(someVariable), string-token DI, reflection wiring.

An ESLint flat config that sets the @nx/enforce-module-boundaries rule to error, with domain (scope:) and layer (type:) depConstraints that Nx combines for every tagged project.

Splitting into an API app and a worker app from one workspace

With boundaries enforced, the orders/billing/users code can feed more than one deployable. A common first split is a public HTTP API and a background worker that consumes queues and runs scheduled jobs. Both are thin app projects. Each owns its bootstrap, configuration, and transport layer (routes in one, queue consumers and schedulers in the other), and delegates everything else to libraries.

   apps/api (HTTP)            apps/worker (queues, cron)
          \                          /
           +------------+-----------+
           |            |           |
        orders       billing      users        domain libraries
           |            |           |
           +------+-----+-----+-----+
                  |           |
             data-access   messaging           infra libraries

Each app has its own build and deploy target, and its artifact contains only what the project graph says it imports. Code is shared through workspace imports rather than published packages. That means you don't need an internal registry, you don't bump versions, and a refactor that touches both apps lands in one commit. A change confined to apps/worker marks only the worker as affected.

The cost shows up at release time. A change to billing marks both apps affected, so CI tests both and your pipeline will likely redeploy both. One commit is not one atomic deploy. During a rollout, the new API can run alongside the old worker. If a shared queue-message DTO gains a required field, or a migration renames a column, the older process fails.

Treat anything that crosses a process boundary as a versioned contract:

  • Make additive changes only.
  • Run expand-and-contract migrations.
  • Write consumers that ignore unknown fields.
  • When removing a field, deploy consumers before producers.
// libs/billing/src/lib/invoice-events.ts
// Shared by apps/api (producer) and apps/worker (consumer).
// During a rollout, old and new builds of each app may run at once,
// so this message shape is a versioned contract, not just a type.

export interface InvoiceRequestedV1 {
  type: 'invoice.requested';
  version: 1;
  orderId: string;
  userId: string;
  amountCents: number;
  // Added later: optional, so old producers that never send it stay valid.
  currency?: string;
}

// The consumer validates only the fields it needs and ignores unknown ones,
// so a newer producer can add fields before every worker is upgraded.
export function parseInvoiceRequested(raw: unknown): InvoiceRequestedV1 | null {
  if (typeof raw !== 'object' || raw === null) return null;
  const msg = raw as Record<string, unknown>;
  if (msg.type !== 'invoice.requested' || msg.version !== 1) return null;
  if (typeof msg.orderId !== 'string' || typeof msg.userId !== 'string') return null;
  if (typeof msg.amountCents !== 'number') return null;

  return {
    type: 'invoice.requested',
    version: 1,
    orderId: msg.orderId,
    userId: msg.userId,
    amountCents: msg.amountCents,
    // Default stays in one place instead of in each app.
    currency: typeof msg.currency === 'string' ? msg.currency : 'USD',
  };
}

// A required field or a changed meaning gets a new version (V2).
// The worker handles V1 and V2 until no API instance emits V1.

A queue-message DTO in the shared billing library. The new field is optional and the parser ignores unknown fields, so old and new API and worker builds can run side by side during a rollout.

Impact on CI time, cost, and engineering time

The savings come from the project graph. Nx knows which projects a change touches, so affected commands lint, test, and build only those projects and their dependents. The computation cache then replays results for any inputs it has already hashed. This skipping only works when dependencies form a graph. If every library imports shared/common, then any edit to it marks the whole workspace as affected. Root-level inputs such as the lockfile, the base TypeScript config, or shared lint settings usually feed into every project's hash, so a dependency bump still reruns nearly everything. Extraction also changes nothing at runtime. The bundler still produces one artifact, so bundle size and startup time stay the same.

Illustrative estimate (assumed numbers, not a benchmark). Use the formula CI time ≈ S + F × A, where S is fixed setup, F is the full lint and test time, and A is the fraction of the graph a typical PR affects. Assume S = 3 min and F = 20 min.

  • Before the split: A = 1, so CI takes 23 min.
  • After the split: assume a typical PR affects a quarter of the graph (A = 0.25), so CI takes 3 + 5 = 8 min.
  • Edits to a shared hub library: these push A back toward 1.
Gains Costs
CI Smaller affected set, cache hits Root config changes still rerun everything
Testing Test scope matches the change More project configs to maintain
Ownership Clear owners per library Migration effort, moving imports

The split is not worth doing if most PRs touch the shared core, the suite already runs in a few minutes, or one small team owns the whole app.

#!/usr/bin/env bash
set -euo pipefail

git fetch --no-tags origin main

BASE="origin/main"
HEAD="HEAD"

npx nx show projects --affected --base="$BASE" --head="$HEAD"

npx nx affected -t lint test build \
  --base="$BASE" \
  --head="$HEAD" \
  --parallel=3

A pull-request CI script that lists the affected projects and then runs lint, test and build only for them, so the cache and project graph can skip untouched libraries.

Alternatives to Nx library extraction

Library extraction is not the only way to get faster CI or clearer ownership. These are the main alternatives, with the trade-off each one makes.

Module Federation for backend code. This loads remotely built code at runtime. It allows truly independent deploys of code that runs in one process, but you take on the runtime version drift and boot-time failure modes described at the start of this guide. It makes sense only if you need hot-swapping code inside a running process and accept that the tested artifact may differ from the running one.

Folder conventions inside one app, with no project split. Organize the single app by domain folders and rely on code review or a generic import-restriction lint rule to keep domains apart. This costs almost nothing to set up and improves ownership. CI does not improve, though, because Nx still sees one project and reruns everything on every change. This option is reasonable when CI time is not the problem.

Separate repositories with published internal packages. Each domain lives in its own repository and publishes versioned packages that the apps consume. Releases are fully independent and the version boundaries are explicit. In exchange, you need a package registry and version bumps for every shared change, and cross-domain refactors become multi-repository coordination. Consumers can also sit on old versions for a long time. This option suits organizations whose teams already work and release independently.

TypeScript project references or plain package-manager workspaces without Nx. These give you separate compilation units and incremental type-checking, and some of the same structure. You would have to build the affected-project detection, task caching, and boundary enforcement yourself in scripts. This option suits teams not already invested in Nx. For a workspace that already uses Nx, it is mostly a step sideways.

Splitting straight into network services. Skip steps 1 to 3 and carve domains out as separate services. This makes sense when a domain already has its own data store and a clear reason to scale or release on its own. Otherwise you get the distributed-monolith risk described under "Finding domain seams," plus the network costs from step 4, often before the domain boundaries have been tested in code.

Speeding up CI without restructuring. Splitting the test suite across parallel CI machines, caching dependency installs, or removing slow tests can cut wall-clock time with no refactor. These measures don't reduce the total work per pull request or clarify ownership. They combine well with library extraction, and they are the right first move when ownership is already clear.

When to avoid it

Beyond those thresholds, a few situations make library extraction actively harmful rather than merely low-value.

The domains share one transaction boundary

If orders, inventory, and billing all write inside the same database transaction, drawing library lines around them produces boundaries that the code cannot honor. You end up with a "shared" library that exports repositories for every table, and every domain library depends on it. Look for this warning sign: when you sketch the target graph, one node has an edge to everything. That graph has the same coupling as before, with more configuration added. Untangle the data model first, or accept the monolith.

The real goal is independent release cadence

Libraries in one workspace still ship together through the apps that consume them. If teams need to deploy on separate schedules, roll back independently, or scale one domain without the others, extraction is only the first step. It does not solve the problem on its own. Do not present it to stakeholders as the solution.

The worker depends on in-process state

Splitting out a worker app assumes jobs can run in a separate process. If handlers rely on in-memory caches, singleton connections set up during API bootstrap, or request-scoped context, the split adds a coordination layer such as a queue or shared store. That is new infrastructure, which is a larger change than a refactor.

The codebase is scheduled for replacement

Boundary work pays off over months of reduced CI and review time. If a rewrite or retirement is already planned, that payoff never arrives.

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 split large backend app, 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.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

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.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • 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.