DEV Community

Sakthikumaran Navakumar
Sakthikumaran Navakumar

Posted on

Anatomy of the v4 Package Graph - Core, Adapters, Runtime, and Orchestrator

Part 2 of an 8-part series on Native Federation v4 for enterprise architecture teams. New to the series? Start with Part 1: The Mental Model, Revisited.

In Part 1, I discussed how Native Federation v4 is a platform, not just a plugin. The main evidence was that v4 split the project into several separate packages, each with its own version.

🤖 A note on this article: I used Claude to help reformat and structure the content to make it clearer and more presentable for publication.

But splitting code into more packages doesn't prove much on its own. The split only matters if each package has a clear job, and if you can upgrade or replace one package without breaking the others.

This article tests that. It walks through the four layers of Native Federation v4 (Core, Adapters, the Classic Runtime and the Orchestrator), explains what each one is responsible for, and shows the one contract that connects them. The rest of the series builds on this map.

A few terms first

If you're new to micro frontends, these terms appear throughout the article:

  • Host: the main application (often called the "shell") that loads other applications into itself.
  • Remote: a separately built and deployed application that the host loads at runtime.
  • Shared dependency: a library, like Angular or RxJS, that the host and remotes all use. Ideally the browser downloads it once and everyone reuses it.
  • Bundler: the tool that turns your source code into files the browser can run, such as esbuild or webpack.
  • Import map: a standard browser feature. It's a small JSON block on the page that tells the browser where to find a module. For example: "when code asks for rxjs, load it from this URL."
  • Semantic versioning (semver): the major.minor.patch version format. A range like ^18.0.0 means "any 18.x version is fine."
  • Singleton: a library that must exist only once on the page. Angular is the classic example: two copies of Angular on one page cause errors.

From one plugin to four repositories

Native Federation used to live inside the angular-architects/module-federation-plugin repository, alongside the same team's webpack Module Federation tools. For v4, the maintainers moved it into its own GitHub organization and split it into four repositories (v3 vs v4):

  • native-federation-core: the core logic. It doesn't depend on any framework or bundler.
  • esbuild-adapter: connects the core to the esbuild bundler.
  • angular-adapter: connects the core to Angular's build tools.
  • orchestrator: loads remotes in the browser.

For each one below, we'll ask the same question: what does it own, and what does it deliberately leave to the others?

The four layers

Core: runs at build time, and only at build time

The core package is @softarc/native-federation. It doesn't know about Angular, esbuild, or how a browser loads a remote. The official architecture overview describes its job in one line: it "normalizes the federation config, bundles shared dependencies and exposed modules, and emits remoteEntry.json + the import map."

That job has three parts.

1. It defines the configuration. Every federated project, whatever framework it uses, is described with the same functions (Core configuration):

  • withNativeFederation() holds the whole configuration: the app's name, the modules it exposes to other apps, and the libraries it shared.
  • fromPackageJson() is the recommended way to list shared libraries. It shares everything in your package.json, and lets you handle exceptions with .skip(), .override() and .patch(). The older shareAll() still works.
  • Each shared library gets a few settings that matter later, when the browser decides which version to load: singleton (only one copy allowed), strictVersion (versions must match), requiredVersion (the acceptable range, where 'auto' reads it from package.json) and includeSecondaries (whether to also share sub-paths like @angular/common/http).
  • sharedMappings handles monorepos, where internal libraries are shared through TypeScript path aliases.

2. It plans the build, but doesn't run the bundler. Core decides what to build. Your app is built without the shared libraries inside it, and each shared library is built separately, so the browser can download it once and reuse it. Core never compiles your code itself. It hands that work to an adapter (Core getting started).

3. It writes the manifest. Core's most important output is a file called remoteEntry.json. Think of it as a remote's label. It lists what the remote exposes and which files contain it. It also lists every shared library, with the version it was built with, the version range it accepts, and its singleton and strictVersion settings.

v4 adds two optional settings that keep this file small in large projects. features.denseChunking groups split code files by bundle, and features.denseExternals groups all the entry points of one library into a single entry (Core configuration).

What Core does not do matters just as much. It doesn't turn TypeScript into JavaScript, and it doesn't decide which library version the browser loads. Because its job is small and clear, it can stay stable while everything around it changes.

Adapters: connect a specific tool to Core

Core works with any bundler in principle, but something has to connect it to a real one. That's all an adapter does. There are two kinds.

Bundler adapters connect Core to one bundler. The esbuild adapter, @softarc/native-federation-esbuild, is the reference one.

This adapter also solves a common real-world problem: CommonJS. Browsers run modern JavaScript modules (ES modules, or ESM). Some popular libraries, React most famously, are still published in the older CommonJS format. The adapter includes a plugin, @chialab/esbuild-plugin-commonjs, that converts them, and its default React setup turns it on for you. The docs also cover libraries that still don't convert cleanly (esbuild adapter getting started). If your project uses older CommonJS libraries, this is where you'll run into them.

Framework adapters make Native Federation feel like a normal part of a framework's tools. The Angular adapter is the most mature. It plugs into angular.json and hands the actual build to Angular's own ApplicationBuilder. So you get every build improvement the Angular team ships, without a separate build process.

You set it up with ng add. The --type option decides what you're creating (Angular adapter getting started):

  • remote: a micro frontend.
  • host: a shell for one environment.
  • dynamic-host: a shell that reads its list of remotes at runtime. This is the recommended default.

The schematic updates angular.json, moves your startup code into a bootstrap.ts file, and creates a federation.config.mjs.

Two details help with planning. First, the Angular adapter's version numbers follow Angular's, so keep them on the same major version. On Angular 20 and 21, v4 is published as @angular-architects/native-federation-v4. From Angular 22, it's back under the original name, @angular-architects/native-federation (Angular adapter getting started). Second, an adapter only translates between a tool and Core. When the Angular adapter changes, it's usually because Angular's build tools changed, not because Core did.

Adapters are the layer most teams work with every day: running the schematic, editing the config, and upgrading with each Angular release.

Classic Runtime: the original browser loader

The Classic Runtime, @softarc/native-federation-runtime, is what most existing projects still use in the browser. Its design was simple. In the words of the docs, it "read remoteEntry.json files, merged them into one ES module import map, injected it into the DOM, and resolved loadRemoteModule() calls against it" (Runtime docs).

In practice, you call initFederation() in the host before the app starts, then call loadRemoteModule() whenever you need a remote. It uses a small library called es-module-shims so import maps work reliably, which is why the startup code is split into main.ts and bootstrap.ts.

The catch is that it doesn't negotiate versions. It has no semver resolution and no caching between page loads (Orchestrator docs). When two remotes want different versions of a library, the docs say they "simply each kept their own" (Runtime docs).

For a small utility library, that just costs extra download time. For a framework, it breaks things. Say the host is built with Angular 18.1.0 and a remote with Angular 18.1.1. The page ends up with two copies of Angular, and you get NG0203 errors (Angular's "injection context" error). Many teams have hit this in production.

The Classic Runtime is now deprecated and end-of-life. The last version is 4.1.2, and its npm page tells users to switch to the Orchestrator (Runtime docs). Existing projects keep working, but there will be no more fixes. Part 4 looks at why its design hit a limit.

Orchestrator: the new browser loader

The Orchestrator, @softarc/native-federation-orchestrator, reads the same remoteEntry.json files as the Classic Runtime. The difference is that it actually decides which version of each library to load (Orchestrator docs).

You pass it around instead of importing it. initFederation(manifest, options) gives you back an object that includes loadRemoteModule, load and initRemoteEntry. You pass that object into your app rather than importing a global function. In Angular, the docs do this with an InjectionToken, so it works through normal dependency injection. The options control logging, storage and how strict version checks are (Orchestrator getting started; configuration).

It remembers what it has already loaded. The Orchestrator keeps four caches (architecture):

  • where each remote lives and what it exposes
  • shared libraries, grouped by package
  • private libraries that belong to a single remote
  • shared code chunks (new in v4)

By default these caches are kept in memory. You can save them with sessionStorageEntry or localStorageEntry, so the next page load doesn't start from scratch (getting started). This helps most with server-rendered sites that reload the whole page on every click.

It picks versions in three steps (version resolver):

  1. Sort. Libraries not marked as singletons skip the negotiation, and each remote simply gets its own copy. Singletons go into a group called a share scope. There's a global scope by default. You can create named scopes, such as "team-a", so one team's libraries never compete with another team's. There's also a special "strict" scope, where every requested version is kept exactly as-is.
  2. Choose. Within each scope, one version of each library wins. If the host declares a version, the host always wins. Otherwise, by default, the Orchestrator picks the version that causes the fewest extra downloads. You can switch that to "pick the newest version" with the latestSharedExternal setting. Remotes that asked for an incompatible version are then handled in one of two ways. With strictVersion: false (the default), they use the winning version anyway and a warning is logged. With strictVersion: true, they download their own private copy.
  3. Write the import map. The winning versions go into the import map's imports section. Private copies go into scopes, a per-remote section. Optional security hashes go into integrity (architecture).

If more remotes show up after the app starts, initRemoteEntry() adds them. It only ever adds to the import map. It never changes a decision that was already made.

Two design choices are worth knowing about early (version resolver):

  • Strictness is all or nothing. You turn on strict.strictExternalCompatibility once, in the options for initFederation(). When it's on, every version mismatch throws an error instead of a warning. You can't be strict about Angular and relaxed about a small utility library in the same scope.
  • The host's version always wins. If a remote ships a newer, compatible patch, the page still loads the host's version.

Neither is a bug. They are deliberate choices, and they affect how you should set up your share scopes. Parts 5 and 6 cover this in depth.

The one contract that connects everything

Core and the adapters run at build time, before you deploy. The Classic Runtime and the Orchestrator run later, in the user's browser. The only thing passed from one side to the other is the manifest, remoteEntry.json.

The architecture overview puts it this way: "the Core and Runtime speak a simple contract — remoteEntry.json plus an import map. Everything else (which bundler, which framework, which runtime) is swappable" (architecture overview).

This is the most important idea in this article. Core doesn't need to know which runtime will read its output. It just writes a manifest in an agreed format. The runtimes don't need to know about each other, or about which Core version wrote a manifest. They just read that format.

That's why the split into separate packages actually works. In practice, it means:

  • You can upgrade Core without every team having to change its runtime at the same time.
  • During a migration, one app can use the Classic Runtime while another uses the Orchestrator, both reading manifests from the same Core version.
  • The Orchestrator can load v3 and v4 remotes on the same page, because both follow the same contract (Orchestrator docs).

Part 7's migration plan depends on exactly this.

Quick reference: which package does what

Keep this table handy. It's useful mid-migration, when you're looking at a lockfile and trying to work out what you're actually running.

Package Layer Status Responsible for
@softarc/native-federation Core Active Configuration, build planning, the remoteEntry.json format
@softarc/native-federation-esbuild Bundler adapter Active Connecting esbuild, converting CommonJS libraries
@angular-architects/native-federation (Angular 22+) / -v4 (Angular 20–21) Framework adapter Active Connecting Angular's build tools, schematics
@softarc/native-federation-runtime Runtime (classic) Deprecated, end-of-life Loading remotes with one merged import map, no version negotiation
@softarc/native-federation-orchestrator Runtime (v4) Active, recommended Loading remotes, choosing versions, share scopes, caching

Check your own project

Before Part 3 looks at what happens during the build, take five minutes to check your own project. Open package.json or your lockfile and look for these:

  • Core: which version you're on, and how far behind the latest release it is.
  • Adapter: which adapter you use, and whether its major version matches your Angular version. On Angular 20 or 21, check whether you're on the -v4 package or still on v3.
  • Runtime: whether you use the Classic Runtime or the Orchestrator. If you see @softarc/native-federation-runtime, you depend on a package that no longer gets fixes. Plan the move now, not after the next incident.
  • Config file: whether your config is still federation.config.js using require() and module.exports. v4 expects federation.config.mjs, written as an ES module, so the old style means your setup predates v4 (migration guide).

This check sounds basic, but it catches the most common problem teams hit when adopting v4. Usually the hard part isn't the concepts. It's finding out halfway through a migration that one layer is several versions behind where everyone thought it was.

Series roadmap

  1. The Mental Model, Revisited: why Native Federation exists and what changed in v4
  2. Anatomy of the v4 Package Graph: Core, Adapters, Runtime and Orchestrator as separate layers (this article)
  3. Build-Time, End to End: what Core and the adapters actually do to your code
  4. The Classic Runtime: what it got right, and where it hits its limit
  5. The Orchestrator: how version resolution and caching work, in depth
  6. Version Drift and Resolution Strategy: keeping many independently deployed apps in sync
  7. v3 vs. v4: a detailed comparison and a practical migration plan
  8. Reference Architecture: running Native Federation v4 in a regulated enterprise

References

Official documentation

  1. Architecture overview
  2. Core: getting started
  3. Core: configuration
  4. esbuild adapter: getting started
  5. Angular adapter: getting started
  6. Classic Runtime (legacy)
  7. Orchestrator overview
  8. Orchestrator: getting started
  9. Orchestrator: configuration
  10. Orchestrator: architecture
  11. Orchestrator: version resolver
  12. v3 vs v4
  13. Migrating to v4

Articles by the maintainers

  1. Auke van Oostenbrugge, Part II: Composing your own native-federation orchestrator
  2. Auke van Oostenbrugge, Part III: Improving Cache Utilization in Native Federation
  3. Auke van Oostenbrugge, Tweaking native-federation, Part II: Sharing externals

Next in this series: **Build-Time, End to End, what Core and the adapters actually do to your code.

Top comments (0)