Skip to content

Module Federation Deep Dive

Webpack Module Federation is the mechanism that wires together all remote Angular apps at runtime. This page covers the host/remote configuration, lazy loading, shared dependency strategy, and how remote URLs are injected per environment.


Architecture Overview


Host Configuration — erp-home

The host app (erp-home) declares every remote in its module-federation.config.ts. Nx uses this file to generate the underlying webpack.config.js.

typescript
// apps/erp-home/module-federation.config.ts
import { ModuleFederationConfig } from '@nx/angular/module-federation';

const config: ModuleFederationConfig = {
  name: 'erp-home',
  remotes: [
    'apps-accounting',
    'apps-hr',
    'apps-finance',
    'apps-sales',
    'apps-purchase',
    'apps-inventory',
    'app-distribution',
    'fixed-assets',
    'bussiness-owners',
  ],
};

export default config;

The generated webpack.config.js extends Nx's base Module Federation webpack config:

javascript
// apps/erp-home/webpack.config.js (auto-generated, do not edit manually)
const { withModuleFederation } = require('@nx/angular/module-federation');
const config = require('./module-federation.config');

module.exports = withModuleFederation(config);

Remote Configuration

Each remote app exposes exactly one entry point: ./Module. This is the Angular NgModule that the host loads lazily.

typescript
// apps/apps-accounting/module-federation.config.ts
import { ModuleFederationConfig } from '@nx/angular/module-federation';

const config: ModuleFederationConfig = {
  name: 'apps-accounting',
  exposes: {
    './Module': './apps/apps-accounting/src/app/remote-entry/entry.module.ts',
  },
};

export default config;

The exposed EntryModule bootstraps the remote's routing and feature modules:

typescript
// apps/apps-accounting/src/app/remote-entry/entry.module.ts
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { AccountingModule } from '../accounting.module';
import { ACCOUNTING_ROUTES } from '../accounting.routes';

@NgModule({
  imports: [
    AccountingModule,
    RouterModule.forChild(ACCOUNTING_ROUTES),
  ],
})
export class EntryModule {}

One Exposed Module Per Remote

Each remote exposes only ./Module. This keeps the federation surface area minimal and prevents partial module leakage between apps.


Lazy Loading in the Shell

The host's router uses loadRemoteModule from @angular-architects/module-federation to load each remote on demand:

typescript
// apps/erp-home/src/app/app.routes.ts
import { loadRemoteModule } from '@angular-architects/module-federation';
import { environment } from '../environments/environment';

export const APP_ROUTES = [
  {
    path: 'accounting',
    loadChildren: () =>
      loadRemoteModule({
        type: 'module',
        remoteEntry: environment.remoteEntries.accounting,
        exposedModule: './Module',
      }).then(m => m.EntryModule),
  },
  {
    path: 'hr',
    loadChildren: () =>
      loadRemoteModule({
        type: 'module',
        remoteEntry: environment.remoteEntries.hr,
        exposedModule: './Module',
      }).then(m => m.EntryModule),
  },
  {
    path: 'finance',
    loadChildren: () =>
      loadRemoteModule({
        type: 'module',
        remoteEntry: environment.remoteEntries.finance,
        exposedModule: './Module',
      }).then(m => m.EntryModule),
  },
  {
    path: 'sales',
    loadChildren: () =>
      loadRemoteModule({
        type: 'module',
        remoteEntry: environment.remoteEntries.sales,
        exposedModule: './Module',
      }).then(m => m.EntryModule),
  },
  {
    path: 'purchase',
    loadChildren: () =>
      loadRemoteModule({
        type: 'module',
        remoteEntry: environment.remoteEntries.purchase,
        exposedModule: './Module',
      }).then(m => m.EntryModule),
  },
  {
    path: 'inventory',
    loadChildren: () =>
      loadRemoteModule({
        type: 'module',
        remoteEntry: environment.remoteEntries.inventory,
        exposedModule: './Module',
      }).then(m => m.EntryModule),
  },
  {
    path: 'distribution',
    loadChildren: () =>
      loadRemoteModule({
        type: 'module',
        remoteEntry: environment.remoteEntries.distribution,
        exposedModule: './Module',
      }).then(m => m.EntryModule),
  },
  {
    path: 'fixed-assets',
    loadChildren: () =>
      loadRemoteModule({
        type: 'module',
        remoteEntry: environment.remoteEntries.fixedAssets,
        exposedModule: './Module',
      }).then(m => m.EntryModule),
  },
];

The remote module is fetched only when the user navigates to its route — not at app startup.


Runtime URL Injection per Environment

Remote entry URLs are configured in each environment file and never hardcoded in the webpack config. This is the key mechanism for pointing the host at locally running remotes, deployed stage remotes, or production SWAs.

typescript
// apps/erp-home/src/environments/environment.ts  (local dev)
export const environment = {
  production: false,
  isDevelopment: true,
  apiBaseUrl: 'https://gateway.microtec-test.com',
  keycloakUrl: 'https://keycloak.microtec-test.com',
  keycloakRealm: 'microtec',
  remoteEntries: {
    accounting:   'http://localhost:4402/remoteEntry.js',
    hr:           'http://localhost:4403/remoteEntry.js',
    finance:      'http://localhost:4404/remoteEntry.js',
    sales:        'http://localhost:4405/remoteEntry.js',
    purchase:     'http://localhost:4406/remoteEntry.js',
    inventory:    'http://localhost:4407/remoteEntry.js',
    distribution: 'http://localhost:4408/remoteEntry.js',
    fixedAssets:  'http://localhost:4409/remoteEntry.js',
  },
};
typescript
// apps/erp-home/src/environments/environment.prod.ts
export const environment = {
  production: true,
  isDevelopment: false,
  apiBaseUrl: 'https://gateway.onlinemicrotec.com.sa',
  keycloakUrl: 'https://keycloak.onlinemicrotec.com.sa',
  keycloakRealm: 'microtec',
  remoteEntries: {
    accounting:   'https://accounting.onlinemicrotec.com.sa/remoteEntry.js',
    hr:           'https://hr.onlinemicrotec.com.sa/remoteEntry.js',
    finance:      'https://finance.onlinemicrotec.com.sa/remoteEntry.js',
    sales:        'https://sales.onlinemicrotec.com.sa/remoteEntry.js',
    purchase:     'https://purchase.onlinemicrotec.com.sa/remoteEntry.js',
    inventory:    'https://inventory.onlinemicrotec.com.sa/remoteEntry.js',
    distribution: 'https://distribution.onlinemicrotec.com.sa/remoteEntry.js',
    fixedAssets:  'https://fixed-assets.onlinemicrotec.com.sa/remoteEntry.js',
  },
};
Environment fileBuild config flagRemote URL base
environment.tsdevelopmentlocalhost:{port}
environment.cloud.tscloud*.microtecstage.com (cloud non-prod)
environment.uat.tsuat*.microtec-uat.com
environment.preprod.tspreprodpreprod domain
environment.prod.tsprod*.onlinemicrotec.com.sa

Mixing Local and Deployed Remotes

During development you can point the shell's environment file at deployed stage remotes for apps you are not touching, and only run the remote you are actively developing locally. See Local Development for the exact workflow.


Shared Dependencies Decision

Shared libraries are NOT in the Module Federation shared config — this is intentional

shared-lib, microtec-auth-lib, and apps-shared-lib are not listed in the shared section of any module-federation.config.ts. Each remote app bundles its own copy of these libraries.

Why not use MF shared?

When libraries are placed in the MF shared config, Webpack negotiates a single singleton instance between host and all remotes. This creates a hard coupling problem:

  1. Incremental deployments break. If accounting is deployed with shared-lib@2.0 and hr still runs shared-lib@1.9, version negotiation fails and one of them loads the wrong version.
  2. Rollback is dangerous. Rolling back one remote can force all remotes to downgrade their shared instance.
  3. Auth singleton issues. microtec-auth-lib holds Keycloak state. Running it as a singleton across remotes from different deployments caused auth state corruption.

The chosen approach

Each remote is a self-contained bundle. shared-lib adds ~200 KB gzipped per remote — an acceptable trade-off for deployment independence. Each SWA can be deployed and rolled back independently without impacting other apps.

Angular and RxJS ARE in the shared config

Angular core packages (@angular/core, @angular/common, @angular/router) and rxjs are in the shared config because they are true singletons — running two Angular instances in one page breaks change detection entirely. These are set with singleton: true, strictVersion: false.


remoteEntry.js

Each remote app's build output contains a remoteEntry.js file. This is the Module Federation manifest — a small JavaScript file that declares what the remote exposes and which chunks to load.

dist/
├── apps-accounting/
│   ├── remoteEntry.js          ← MF manifest, loaded by the host first
│   ├── main.abc123.js          ← App bundle
│   └── ...

The host fetches remoteEntry.js before the user navigates to that module. If the remote is unreachable, the lazy-loaded route fails gracefully with an error boundary.


Adding a New Remote App

  1. Generate the Nx app: nx g @nx/angular:app apps-newmodule --port=4410
  2. Add module-federation.config.ts exposing ./Module.
  3. Add the remote name to apps/erp-home/module-federation.config.ts's remotes array.
  4. Add the route in app.routes.ts with loadRemoteModule.
  5. Add the remote entry URL to all environment files.
  6. Add the Azure SWA deployment to the CI/CD pipeline config (services-config.json).

Troubleshooting

Remote fails to load — ScriptExternalLoadError

The host cannot fetch the remote's remoteEntry.js. Check:

  1. Is the remote app running on its expected port?
  2. Does the environment file point to the correct URL?
  3. Is CORS enabled on the remote's SWA? (Azure SWA allows cross-origin by default for static files.)

NullInjectorError after navigation to a remote

The remote module is missing a provider. Check that the EntryModule imports all required modules (especially HttpClientModule or provideHttpClient()).

Two Angular instances detected in console

A library outside the shared config is accidentally bootstrapping Angular twice. Check that no remote imports Angular directly via a path outside node_modules (e.g., path aliases pointing to source).

Internal Documentation — Microtec Platform Team