Hi. It's been a while. I hope you are doing great these days. Today, I want to show you something I've been playing with recently.
In this modern world, applications seem to be growing in all directions, and then you need this larger team to deal with your big app, and eventually, you found that you have several smaller teams, working only on a section of the application each of them, yet they all need to download, run, build, and test, the whole application. If you are in this position, the micro-frontend approach might suit your desire to scale better.
What's a micro-frontend?
I don't want to focus this post on what a micro-frontend is, but I will try to introduce this concept briefly. By now we all know microservices, that is instead of having a large monolithic application serving all your requests, we divided it into services, allowing each service to be developed, tested, built, and delivered individually. Micro-frontend is the same concept but applied to front-end applications. This approach allows us to create smaller applications, that can be developed, tested, and built individually and will be integrated as part of a larger application, probably using routing to serve each application.
From the user's point of view, they will be using one application. From the development point of view, it depends on how you want to serve the applications. You could serve them individually, package them all as one application using advanced techniques like module federation, or use other techniques like the one I am about to present.
For this example, we will be ending up building one application, that will import all other applications, but each application can be locally served, tested, and deployed, individually.
Current micro-frontend state of art
Since micro-frontend allows us to develop applications individually, this also means you can use different frameworks and libraries together to develop each section of this application. Of course, this will have some advantages and disadvantages. If you are using different technologies to serve different parts of the application, it won't be so easy to move developers within teams. However, the door is open, and if you want to do something like that, you could use something like Single SPA, or some other, to help you organize and connect the different technologies into a larger application.
You can also use just one frontend tool, like React, Vue, or in this case, Angular. Each of them has some way to achieve what we need. If you prefer doing in this way, one tool you could use is Nx. It has some really cool features such as depency graph, linked package, and others. It also has first-class support for Module Federation and Micro-frontend development.
In the end, what we want is to able to build, test, and deploy each application on its own, and to serve all of them to the user like they are using just one app.
How to split the hydra?
So, you may have this big application, and you are noticing that tests are taking really long to finish, and building takes even longer. Even serving locally the app takes what seems forever. And you had read something about micro-frontends and spitting the app into mini-apps can help you reduce all of this. You are right.
I know that I've said it before, but I'll say it again: You have many alternatives to implementing micro-frontend. For sure you are going to find other guides describing other methods, or maybe even some others with the same method I'm using.
The ones that I've found describe how you should use the micro-frontend approach from scratch, and surely, if you are starting a project and you know ahead is going to be big enough to need this architecture, maybe you could check those first and see if that is something that fits want you need better.
But this is not about creating an app from scratch using a micro-frontend, this is about refactoring an existing application, that you might have already created using Angular CLI, into shared libraries, and individual apps, that can be integrated within the same larger app seamlessly.
Review the base application
We are going to use this app for the experiment. You might notice is not really a big app, that's because it's not even a real app. But you might also notice that the application uses a couple of services that will allow us to see how these services will integrate with each application.
The basic structure of the application is the same one you get from creating a new project with Angular CLI. Within the application, we have a couple of services, models, and feature modules. The goal of this will be to refactor the services and models into libraries that can be shared, and the feature modules into their individual apps.
- src
+ features
- add-user
- dashboard
- login
- user-list
- models
+ shared
- auth
- users
The routing currently uses lazy loading, as you can see in the following code extract:
// app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { isLogged } from './shared/auth/is-logged.guard';
import { isNotLogged } from './shared/auth/is-not-logged.guard';
const routes: Routes = [
{
path: '',
canMatch: [isLogged],
loadChildren: () =>
import('./features/dashboard/dashboard.module').then((m) => m.DashboardModule),
},
{
path: '',
canMatch: [isNotLogged],
loadChildren: () => import('./features/login/login.module').then((m) => m.LoginModule),
},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
You can find the source code that we are going to use in this repo
Start easy
Migrating a complex application won't be easy, and I'll try to provide some clear pathways that you can use to do it. However, it would help if you considered that you might need additional tooling, especially to identify better the dependencies between your files.
Because first we are going to identify some files that don't have dependencies, but with files that depend on them, create a new library and move those files to that library. In this case, we have the models
folder, containing the only model used in the application, and that model doesn't depend on any other file, but is used across services and feature modules.
Now, to create the library, we are going to use the following Angular CLI command
ng generate library models
This will create a new folder named projects, and inside that folder, our library will be created. Additionally, this will install the ng-packagr
as a root dependency, update the angular.json
file to include the models
library project description, and update the tsconfig.json
file with a path mapping to the built version of the library. By default, the path will be the name of the library, but I would recommend updating it to something less likely to conflict with an actual npm module. I'm using @@
as a prefix here because you can't use double at sign as an npm module name.
Additionally, you could also update the name in the
package.json
file of the library. You might get a warning, but because this is not going to be published on a register you should be fine. However, if you want to publish it on a register you should use a register-safe name then.
Having the library created, we are going to move the models
folder content into this new library. You might notice that this library will create a component, a module, and a service for you by default, we can remove all of them, and then place the user model file inside the lib
folder of that library. Update the public-api.ts
file to export what should be the public content of the lib
folder. In the end, you should have something like this:
To move files around you might use the
git mv
instead of the OS features to allow git to track better the changes. Also, you can review the additional files generated by this command if you want, but we will play with them later anyway.
After that, we are going to update the imports to use this library. So, instead of using this as:
import { User } from 'src/app/models/user';
We are going to update to:
import { User } from '@@models';
Remember to use the path you have in your
tsconfig.json
file.
By now you should have some errors because we haven't built the library yet. So, if we run:
ng build models
The Angular CLI will build the models library, and now TS will properly get your @@models
references.
We are halfway there
Well, not really, but I think this is getting long. Anyway, after you have migrated all your non-depended code into a library, you can start identifying some dependent code that can be isolated into libraries as well. To continue with this example, we are going to migrate now one of the services, specifically the auth folder, including the guards. This will be the same as the models, create the library, move the folder content into the lib
folder, build, and update the references.
Run the Angular CLI command to create the auth
library.
ng generate library auth
Delete the content of the lib
folder and move all the auth
folder content into the lib
folder. Finally, update the public-api.ts
file. You should have something like this:
And your public-api.ts
file should have something like this:
/*
* Public API Surface of auth
*/
export * from './lib/auth.service';
export * from './lib/is-logged.guard';
export * from './lib/is-not-logged.guard';
Run the Angular CLI command to build the auth
library.
ng build auth
You can now update the imports of the auth service and guards to use the library.
Congrats!
You have migrated some parts of your application into libraries. And if you followed it correctly, the application is still running! Probably even slightly faster, because Angular doesn't need to build the whole application to serve it. Now you have several libraries, in our case two, with isolated testing, and building.
In this example, the application is sharing only a model and a couple of services, but in a larger application, you could be sharing components, services, pipes, and other pieces.
Additionally, to create a new library for each piece you want to share, you could use the Secondary entrypoints feature of
ng-packagr
to group common things together, like components or services, and group them together by a more specific feature. But consider this will requires additional updates to the configuration.
Migrating the first app
So far, we have seen how to migrate simple parts of the code into libraries. This will allow us to reuse them between our apps, but we are also learning how to package a library.
To migrate the first app, I will suggest that at least you have all shared artifacts the selected app is using in their own libraries. You might notice that we are still having the users
service. But since the module we are going to migrate is not using this service, we can safely migrate that service later.
Don't worry if things don't work the first time. A migration like this will take time. I'm only showing you how I could do it right, but not the times I failed. Remember, Git is your best friend in this case, create as many branches as you need.
The first thing we need to do is to identify the feature module we are going to migrate. For this example, if you had reviewed the code, you might already guess, we are going to migrate the Login feature module.
We can create the application using the Angular CLI command:
ng generate application login --style=scss --routing
The
--style=scss
is optional, you can use the style you want, but the--routing
is strongly suggested, you will see why shortly.
With this, we will have a new login
application within the projects
folder, and the angular.json
updated with the application. To try the recently created application, you need to run the Angular CLI command:
ng serve login
If you are running the main application, you will see this message:
? Port 4200 is already in use. Would you like to use a different port?
You could select yes, and a random port will be used, update the
angular.json
file or use the--port
option in the CLI, to have a fixed port instead.
We are going to move the feature module into the application, but instead of replacing the existing files as we did with the libraries, we are going to create a feature
folder in the src
folder of the new application and move the login
folder into it. You should have something like this:
We are going to update the login
app to serve this feature module with the root route.
// app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
const routes: Routes = [
{
path: '',
loadChildren: () => import('./feature/login/login.module').then((m) => m.LoginModule),
},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
<!-- app.component.html -->
<router-outlet></router-outlet>
If you did everything correctly, you should have now an unstyled application. That's because the original application was using Material, and we need to set up Material SCSS for this application as well. For now, we will only copy and paste the styles.scss
file from the main app into this one. In a later post, we will see how to share styles between apps.
After this, you should have the application running and looking good.
Integrating the Login app within the main app
We have the individual application up and running, however, we still need some way to integrate it with the main app. What we are going to do to do so, is to build the application and import it using the lazy load feature the main module is using already. However, we are going to build the application as a library.
To set this up, we need to manually create a project configuration in the angular.json
file and some files that will be used by ng-packagr
. Luckily for us, we have some libraries already that we can use as a template for those that we need to create.
Updating the angular.json
Find a library configuration, and copy and paste all of it just under the login
configuration, for them somewhat to be grouped. Change the name (the key) of the configuration to login-lib
. Update the root
, sourceRoot
, and others that reference the original library with references to the login application. Usually what you need to do is to rename the original folder name of the library with login
, but for the typescript config files, you could create them, or use the ones you have (The applications are created with one less file than the libraries, for some reason).
The configuration should look something like this:
As you can see, we don't have the ng-package.json
file, so let's going to create it.
Create the ng-package.json
file
The ng-package.json
file describes how ng-packagr
should package the application. It doesn't have any relation to a regular package.json
file.
We are going to copy the ng-package.json
file from one of the existing libraries and paste it into the login
folder, the root folder of the Login application. If you open the file, you can notice three keys: $schema
, dest
, and lib
. The first is used to help some editors to verify the schema of the JSON file, the second is used to define where the library will be built, and the last is an object to configure the library, including the entryFile
. We are going to update the dest
value to "dest": "../../dist/login-lib"
. This will tell ng-packagr
to build our application as a library in that folder. The content of the ng-package.json
should be something like this:
Again, you might notice that we don't have the public-api.ts
file.
Creating the public-api.ts
file
With this one, we don't need to copy and paste, because this will be different from the ones libraries normally use.
Create a file under the src
folder and name it public-api.ts
. Open it and export just the Login feature module.
// public-api.ts
export { LoginModule } from './app/feature/login/login.module';
Building the library
You might have guessed by now, but the goal here is to create a library with the feature module as the only thing being exported. To do that, ng-packagr
needs a package.json
as well in the Login application root folder. You can copy one from another library but be sure to update the name of the project.
Now run the build Angular CLI command
ng build login-lib
And you should have a newly built library feature module.
Importing the library
All those files that we needed to create, are created by us when we use the ng generate library
command. Additionally, it also updates the tsconfig.json
file to path map to the built version of those libraries. We are going to do exactly that.
Open the tsconfig.json
, and in the paths
object, add another key with the name that you will be using for the library, and point the path to "dist/login-lib"
. Your path configuration should look something like this:
If you are using other names to map your paths, is ok, just make sure that you are pointing correctly to the built version of each library.
After you have updated your tsconfig.json
, the only thing you need to do is update the app-routing.module.ts
.
From this:
loadChildren: () => import('./features/login/login.module').then((m) => m.LoginModule),
We are going to update to:
loadChildren: () => import('@@login').then((m) => m.LoginModule),
After this, you should be able to serve the application as usual.
You have your first application migrated away!
Awesome! If you follow along, you can now safely serve the application, and it will work as before. But internally, you have now separated projects for each slice of your application, where you can run separate commands, reducing the scope of each, and improving the time each take to run each command.
Opportunities
With the current code, each time we clone the code, or update a library, including one of the apps, we would need to manually run the build command. But, because they are not being package together, we won't need to do this for their dependents, all of them will work assuming we didn't break anything. Additionally, this also means we are not using versions for handling each library, so it could become difficult to introduce a breaking change.
We could improve part of this by using something like Lerna. With the right configuration, Lerna can be really helpful.
That's all folks!
As usual, thank you for reading. I really appreciate the time you all take to read my articles. We are more than 5000 now, so thank you all. I really hope you can find this useful. I don't think another post to migrate the other features modules are necessary, but I'll try to do one about sharing styles, or maybe something about configuring the secondary endpoints of the ng-packagr
. I could also do a post about configuring Lerna to work with this and improve the dependency building. But I think it would be better if you let me know in the comments what would be more interesting for you.
Photo by Christian Holzinger on Unsplash