The architecture of module dependencies in Angular

Picture of chess pawn wearing a crown.
Photo by Martin Damboldt from Pexels

Every good programmer should know this famous Robert C. Martin concept "Good architecture allows us to delay making serious decisions as long as possible". This quote should be taken into consideration every time an architectural decision is being made. It is especially important in today's frontend development where everything is rapidly changing. There are new JavaScript frameworks being made every week, trends differ from day to day. The era of the Agile nourishes this type of development and raises the level of difficulty in designing an application. Our responsibility as developers is to know how we can make an architectural decision that we will not regret in the future.

Image of Web technologies fatigue
Web technologies fatigue.

The Angular application is a very specific environment when it comes to designing a project structure. The said framework is mostly used in enterprise-scale applications, which usually consist of many domains connected to each other by many dependencies. Angular modules are great tools that give us a lot of flexibility and have great potential for structuring projects for the future. Unfortunately in many cases, they also act as anchors holding us down from achieving really fluid architecture. By this I mean an architecture that can easily be changed or refactored whenever necessary. In this article, I would like to present a couple of concepts on how to design a multi-domain Angular application.

Customer Application

In my option the best way to learn something is by doing it, so let's try to build a simple application that presents a list of customers. In order to do this we need three key parts:

  • CustomerListComponent for presenting customer,
  • CustomerService for supplying data to the component,
  • CustomerModule for keeping it all together.

After creating these three classes our project structure should look like this:

Image of Application structure.
Application structure.

It is important to notice that our CustomerListComponentneeds to present information about a Customer like a name, position and company. This is a very simple application and it should look like this.

Image of application presenting a list of customers.
Example application presenting a list of customers.

The whole application code is available in the repository: https://github.com/generic-ui/angular-module-architecture

Employee list

Let's try to add another domain to our application, now it needs to present a list of employees alongside a customer list.

Naturally, we have to do the same thing as before, so we create a separate EmployeeModule with an EmployeeListComponent and an EmployeeService.

Image of Employee domain structure.
Employee domain structure.

Now we have two separate modules for two different domains: Customers and Employees.

Both the Customer and the Employee list present information about people. It's good to create the UI in a coherent way because it leads to a better user experience. Taking that into consideration we should create a separate PersonComponent that will help us present information about customers and employees in the same way.

We create the PersonComponent and we add the declaration to two modules: the CustomerModule and the EmployeeModule . Unfortunately, Angular architecture will not allow us to do this, the compiler will throw an error:

Image of Error Component declared in two separate angular modules.
Component declared in two separate angular modules.

In order to deal with this issue, we need to create a PersonModule that will be used by both the customer and the employee domains. In my opinion, every component tied to a specific domain should be part of an Angular module. Every time you want to use a specific component you shouldn't simply import it into any of your modules. This approach will cause problems in the future. What if the implementation of this component changes and it will require a specific service or a child component? Now in every place where you previously imported it, you will be forced to add new dependencies. It's better to avoid this situation because it is not flexible and it doesn't scale well. The right approach is to create an Angular module instead, which will save you a lot of time because it's more resilient to changes.

Let's get back to code. As we said earlier we need to create a PersonModule with a PersonComponent which will be used by both the customer and the employee domains. Then we import the PersonModule into the CustomerModule and into the EmployeeModule so it is accessible to them both.

@NgModule({
  declarations: [
    CustomerListComponent,
  ],
  exports: [CustomerListComponent],
  imports: [
    CommonModule,
    PersonModule,
    GuiListModule
  ]
})
export class CustomerModule {
}

The EmployeeModule imports shared PersonModule.

@NgModule({
  declarations: [
    EmployeeListComponent
  ],
  exports: [EmployeeListComponent],
  imports: [
    CommonModule,
    PersonModule,
    GuiListModule
  ]
})
export class EmployeeModule {
}

The Person domain creates relations which can be modeled with the dependency graph:

Image of Application dependency graph.
Application dependency graph.

After all the code implementation we have the application running correctly and presenting two lists on one page.

Image of Example of application presenting two lists of customers and employees.
Example of application presenting two lists of customers and employees.

Person unique identifier

Each person presented in our application needs to have a special id. It needs to be unique in the scope of the whole application. In order to achieve this condition, we need to create a PersonIdGenerator which will handle the generation a unique id and it will be used in both the customer and the employee domains. The PersonIdGenerator should be a singleton in the application scope so it can generate unique ids for both domains.

Image of Customers and Employees with unique ids.
Customers and Employees with unique ids.
@Injectable()
export class PersonIdGenerator {

  private index = 0;

  generate(): number {
    return this.index++;
  }

}

At this point, I would like to state that we could use the providedIn: 'root' approach to provide a class in the root context, but in my opinion, it is not a good technique when it comes to creating an application from separate independent domains. By using providedIn mechanisms we lose the information about which domain the module uses and what it requires. There is no simple method of displaying what the root context has without running the application. From my point of view, this approach makes the code less readable and generates more potential bugs in the future.

The PersonIdGenerator needs to provided in the PersonModule.

@NgModule({
  declarations: [PersonComponent],
  exports: [PersonComponent],
  imports: [CommonModule],
  providers: [PersonIdGenerator]
})
export class PersonModule {
}

Routing

Let's try to improve our application by separating the customer and the employee lists and presenting them on two different routes. New pages should be lazy routes, so they will be loaded and initialized only after the user decides to visit them.

Image of Application routes structure.
Application routes structure.
@NgModule({
  imports: [
    CustomerModule,
    CustomerPageRoutingModule
  ],
  declarations: [
    CustomerPageComponent
  ]
})
export class CustomerPageModule {
}

For each route, we need to create a separate Angular module with its own routing.

@NgModule({
  imports: [
    RouterModule.forChild(routes)
  ]
})
export class CustomerPageRoutingModule {
}

The only issue with our improved version of the application is that somehow we lost the uniqueness of the user id. Both the customer list and the employee list present objects which have no unique identifiers.

Image of Duplicated ids in both of the lists.
Duplicated ids in both of the lists.

Lazy loaded modules create a separate context, so when we provide any service at the lazy module-level it is a singleton in the context of this route. So when we provide two declarations of the same service on two different lazy routes, we create two instances of this service, one for each route. The obvious solution for this issue is to move import of the PersonModule to the AppModule, which will make the PersonIdGenerator a singleton in the context of the whole application.

@NgModule({
  declarations: [PersonComponent],
  exports: [PersonComponent],
  imports: [CommonModule]
})
export class PersonModule {

  static forRoot(): ModuleWithProviders {
    return {
      ngModule: PersonModule,
      providers: [PersonIdGenerator]
    };
  }

  static forChild(): ModuleWithProviders {
    return {
      ngModule: PersonModule,
      providers: []
    };
  }
}

We need to prepare the PersonModule in a way that it can be used both by the AppModule of the application and by any lazy route modules. So in order to do this we need to create two methods:

  • forRoot - returns the module with provided services, used at the root level of the application,
  • forChild - returns the module without any services, used by the lazy modules.

You can find more about this pattern in the article "Famous Angular forRoot pattern".

The main AppModule needs to import the PersonModule.forRoot().

@NgModule({
  declarations: [PersonComponent],
  exports: [PersonComponent],
  imports: [CommonModule]
})
export class PersonModule {

  static forRoot(): ModuleWithProviders {
    return {
      ngModule: PersonModule,
      providers: [PersonIdGenerator]
    };
  }

  static forChild(): ModuleWithProviders {
    return {
      ngModule: PersonModule,
      providers: []
    };
  }
}

Our CustomerModule imports PersonModule.forChild():

@NgModule({
  declarations: [
    CustomerListComponent,
  ],
  exports: [CustomerListComponent],
  imports: [
    CommonModule,
    PersonModule.forChild(),
    GuiListModule
  ]
})
export class CustomerModule {
}

Split Person domain

Another solution is to extract the service PersonIdGenerator to its own domain. There is nothing specific about this service, nothing links it with the Person class, so it can be simply an IdGenerator provided in the GeneratorModule.

@Injectable()
export class IdGenerator {

  private index = 0;

  generate(): number {
    return this.index++;
  }

}
@NgModule({
  providers: [
    IdGenerator
  ],
})
export class GeneratorModule {
}

Now we can modify the PersonIdGenerator to use the IdGenerator, and import the GeneratorModule into the AppModule.

@Injectable()
export class PersonIdGenerator {

  constructor(private readonly idGenerator: IdGenerator) {}

  generate(): number {
    return this.idGenerator.generate();
  }

}

You can see the full example code in the repository https://github.com/generic-ui/angular-module-architecture.

The only issue with this solution is that the PersonModule is dependant on the GeneratorModule, but there is no information in the modules themselves that they need it. If we decide to take the whole PersonModule and move it to a different application, we have no information in the code that it requires another module. Only when we try to run the application do we receive a runtime exception that something is missing. That something may be an IdGenerator, but Angular will not tell us to import the whole GeneratorModule. What if our module will require more than one external module? We can always help ourselves with dependency charts but it would be great if we could resolve this issue in the code.

Image of Dependency graph.
Dependency graph.

Required dependencies

Let's try to think about how can we find the answer to this question. How can we make it simpler for ourselves to describe which dependencies are required by a specific Angular module? As mentioned above, we could use dependency charts, but is there a way for the code to make it obvious to us?

When you take a look at the implementation of the PersonModule, you cannot say what is needed in order to use it, without going deeply into the implementation of all of this module's declarations and providers. So what can we do to avoid painful bugs and wasting time on reading the code of all our dependencies? We can inject all the other required modules into the PersonModule constructor!

@NgModule({
  declarations: [PersonComponent],
  exports: [PersonComponent],
  imports: [CommonModule]
})
export class PersonModule {

  constructor(generatorModule: GeneratorModule) {}

  // rest of the module

}

When the GeneratorModule is not available in the application context, Angular with throw runtime exception.

This technique allows you to:

  • Verify which dependency is required right away by looking only at the PersonModule implementation (We can use it as documentation),
  • Angular will throw specific runtime exceptions which will help you identify a missing dependency without any effort.

This approach helps us to be sure that our module has all of its required dependencies without going deeply into the implementation.

Summary

When it comes to the application architecture, Angular modules are the most important part of the framework. Good design alongside correct separation of your domains allows you to create an application that can be improved and developed in the future without any major issues. This is especially important in these times of constant change, of shifting client requirements and of raising the bar of application performance expectations.

Example code can be found here: https://github.com/generic-ui/angular-module-architecture

Luke, Software engineer, passionate about best practices, frameworks React, Vue, patterns and architectures for modern web
				development, open-source creator, TypeScript enthusiast, Generic UI Technical Blog

Luke

Software engineer, passionate about best practices, frameworks React & Vue, patterns and architectures for modern web development, open-source creator, TypeScript enthusiast.