angularmodulelazy-loadingmicro-frontendangular-module-federation

How to pass a service in angular lazy loaded microfrontend


I have a microfrontend architecture. In that, I have an app called central, and I have other microfrontends that are defined in as routing in central routing module. All mfes are loading from that central and this central app has the service called LoginService in it's app.module.ts

Below is example how central loads other microfrotends via it's routes

  {
    feature: featuresEnum.USER_OVERVIEW,
    props: {
      path: 'users',
      loadChildren: () => loadRemoteModule(getConfig('useroverview')).then(({ AppModule }) => AppModule),
      canActivate: [() => inject(RouteGuardService).canActivatePath({ permittedUse: permittedUseCasesEnum.MGMT })],
    },
  },
  {
    feature: featuresEnum.CONTACT,
    props: {
      path: 'user',
      loadChildren: () => loadRemoteModule(getConfig('contact')).then(({ ContactModule }) => ContactModule),
    },
  },
  {
    feature: featuresEnum.ADMINPANEL,
    props: {
      path: 'adminpanel',
      loadChildren: () => loadRemoteModule(getConfig('adminpanel')).then(({ AppModule }) => AppModule),
    },
  },
  {
    feature: featuresEnum.PERMISSIONS,
    props: {
      path: 'permissions',
      loadChildren: () => loadRemoteModule(getConfig('permissions')).then(({ UserPermissionsModule }) => UserPermissionsModule),
    },
  },
  {
    feature: featuresEnum.MFA,
    props: {
      path: 'mfa',
      loadChildren: () => loadRemoteModule(getConfig('mfa')).then(({ MfaModule }) => IModule),
      canMatch: [() => inject(RouteGuardService).canActivatePath({ permittedUse: permittedUseCasesEnum.MFA })],
    },
  },
  {
    feature: featuresEnum.PROFILE_INFORMATION,
    props: {
      path: 'profile',
      loadChildren: () =>
        loadRemoteModule(getConfig('profileinformation')).then(({ AppModule }) => AppModule),
      canMatch: [() => inject(RouteGuardService).canActivatePath({ permittedUse: permittedUseCasesEnum.USER_MGMT })],
    },
  },
];

Now, this routing file is called ia.routing.module.ts

This is then used in a file called, ia.module.ts like below

  declarations: [IaComponent, NavigationComponent, LoginComponent, ProfileComponent, ErrorPageComponent],
  imports: [
    CommonModule,
    IaRoutingModule,
    TranslateModule.forChild(TRANSLATIONMODULE_CONFIG),
    BreadCrumbNavigationModule,
    IasApiModule.forRoot(environment.env),
    PfwApiModule.forRoot(environment.env),
    SharedDataStoreModule,
    EffectsModule.forRoot([]),
    StoreDevtoolsModule.instrument({ maxAge: 25 , connectInZone: true}),
  ],
  providers: [
    {
      provide: ToggleService,
      useFactory: () => new ToggleService(environment.env),
    },
    {
      provide: ENVIRONMENT,
      useValue: environment.env,
    }
    ],
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  bootstrap: [IamComponent],
})
export class IaModule {
}

Then, this IaModule is used in appModule where the service, called LoginService is provided in the providers array like below

  declarations: [AppComponent],
  providers: [
    LoginService,
    PermittedUseCasesService,
    AuthService,
    {
      provide: OAuthStorage,
      useValue: sessionStorage,
    },
    TranslatePipe
  ],
  imports: [
    BrowserModule,
    HttpClientModule,
    OAuthModule.forRoot(),
    TranslateModule.forRoot(),
    StoreModule.forRoot({}),
    RouterModule.forRoot([
      {
        path: 'ia',
        loadChildren: () => import('./ia.module').then((m) => m.IaModule),
        resolve: {
          loginService: LoginService,
          permittedUseCasesService: PermittedUseCasesService
        },
      },
    ]),
  ],
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  bootstrap: [AppComponent],
})
export class AppModule {

}

Now, this was the central app that exposes all other microfrotneds. All microfrotends are accessible through this shell by prefixing /ia path. So, if mfe name is profile, the path will be /ia/profile. Now, In the profile microfrontend, I am defining the routes like below

  {
    path: '',
    component: ProfileComponent,
  },
  {
    path: '',
    pathMatch: 'full',
    redirectTo: 'profile',
  },
  {
    path: '**',
    redirectTo: 'profile',
  },
];
@NgModule({
  declarations: [ProfileComponent],
  imports: [
    CommonModule,
    RouterModule.forChild(routes),
    TranslateModule.forRoot(TRANSLATIONMODULE_CONFIG),
    SharedModule,
    UnidApiModule.forRoot(environment.env),
    PfwApiModule.forRoot(environment.env),
  ],
  exports: [RouterModule],
  providers: [
    LoginService,
    {
      provide: FeatureToggleService,
      useFactory: () => new FeatureToggleService(environment.env),
    },
  ],
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class ProfileModule {
}

Lazy loading it like below

  {
    path: '',
    component: AppComponent,
    children: [
      {
        path: '',
        loadChildren: () => import('./profile.module').then((m) => m.ProfileModule),
      },
    ],
  },
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],

})
export class AppRoutingModule {}

and then importing AppRoutingModule in AppModule of Profile Microfrontend like below

  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    SharedModule,
    AppRoutingModule,
    HttpClientModule,
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

Now, In Profile module, I cannot access LoginService. It seems to not have been initialized and I get empty result for the function I am using for LoginService. If I do not lazyload the Profile microfrontend, I can access the LoginService. One thing to mention is that, I do not have access to the LoginService source code. It is an npm dependency I am using. There is a resolve function in the LoginService like below

resolve(): Observable<LoginService>;

I tried creating a resolver for it and used it in my app-routing module of my profile microfrontend like below, but it did not work and doing this way, I cannot even load the profile microfrontend in browser, it just gets stuck which makes sense as the LoginService is not resolved

import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { LoginService } from '@thirdparty/loginService';

@Injectable({
  providedIn: 'root'
})
export class LoginResolver implements Resolve<LoginService> {
  constructor(private loginService: LoginService) {}

  resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<LoginService> {
    return this.loginService.resolve();
  }
}

Using the LoginResolver

{
    path: '',
    component: AppComponent,
    children: [
      {
        path: '',
        loadChildren: () => import('./profile.module').then((m) => m.ProfileModule),
resolve: {
          loginService: LoginResolver
        }
      },
    ],
  },
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],

})
export class AppRoutingModule {}

Solution

  • It is good that you already have LoginService as a citizen of some library. One of the features of module federation is it can resolve and reuse the same libraries for different apps. i.e. if shell app imports LoginService from 'X', apps should easily reuse the exact same instance of a library (and service).

    There could be different issues why injection doesn't work for you. Lets start from the simplest one and lets hope others do not apply.

    You provide instances of the same modules (and a service) in the Profile module:

    @NgModule({
      ...
      imports: [
        ...
        RouterModule.forChild(routes), // good forChild is used, and not forRoot
        TranslateModule.forRoot(TRANSLATIONMODULE_CONFIG), // this overrides TranslateModule. could be a problem, but depends on how related are shell and profile module translations
        SharedModule, // most likely an issue. all providers will be provided again
        UnidApiModule.forRoot(environment.env),  // most likely an issue. all providers will be provided again
        PfwApiModule.forRoot(environment.env), // most likely an issue. all providers will be provided again
      ],
      providers: [
        LoginService, // definetly a problem. It is overriding the provider and creating another copy
        {
          provide: FeatureToggleService,
          useFactory: () => new FeatureToggleService(environment.env),
        }, // most likely a problem. It is overriding the provider and creating another copy
      ],
    })
    export class ProfileModule {
    }
    

    Duplicate providers are most likely bad for the application, because in the subtree of ProfileModule new unique instances of all reprovided services will be used. You should remove LoginService(and probably other marked modules) from providers array. This way you would be able to inject LoginService in a most simplistic way without any tricks.

    class AnyComponentOrService {
      constructor(private loginService: LoginService) {}
    }
    

    These providers and .forRoot calls should be stated in the AppModule of the profile application. AppModule there exists for "emulating a shell", so it would be easier to develop profile application in isolation. This AppModule is not used at all in the resulting frankenstein federated application on the actual deployed environment in the majority of cases.

    At first glance fixed ProfileModule should look like this:

    @NgModule({
      declarations: [ProfileComponent],
      imports: [
        CommonModule,
        RouterModule.forChild(routes),
        TranslateModule.forRoot(TRANSLATIONMODULE_CONFIG), // not sure if it should be here
        SharedModule,
      ],
      exports: [RouterModule],
      schemas: [CUSTOM_ELEMENTS_SCHEMA],
    })
    export class ProfileModule {
    }
    

    Another issue is misconfiguration or incorrect versions of libraries. This can cause different instances of the same library to exist in the runtime. It would also cause inability to inject LoginService in the profile application, even if it is already provided in the shell one