Using ES2015 modules
So far, we have mentioned that models are just plain classes. An ES2015 module is just one file. Within that file lives both public and private constructs. Things that are private are only visible within that file. Things that are public can be used outside said file. In Angular, Es2015 modules aren't used only for models but for all imaginable constructs such as components, Directives, Pipes, Services, and so on. This is because ES2015 modules are an answer to how we split our project into smaller parts, which provides us with the following benefits:
- Many small files makes it easier to parallelize the work you do and have many developers work on it at the same time
- The ability to hide data by, making some parts of your application public and some other private
- Code reuse
- Better maintainability
We have to remember what web development used to look like to understand these statements. When the web was young our JavaScript code more often than not consisted of one file. That quickly became a huge mess. There have been different techniques over the years to find a way to split up our app into many small files. Many small files have made it easier to maintain and also to get a good overview of what is going on, among many other benefits. There have been other issues though. As all these small files had to be stitched back together before being shipped with the app, a process called bundling, we suddenly had one giant file where functions and variables could by mistake affect each other due to naming collisions. A way to attack that problem is to deal with something called information hiding. This to ensure the variables and functions we created are only visible to certain other constructs. There are multiple ways, of course, to address this issue. ES2015 has a private by default way about them. Everything declared in an ES2015 is private by default unless you explicitly export it, thereby making it publicly accessible to other modules that import the aforementioned module.
So how does this connect to the previous statements? Any module system really allows us to maintain visibility in our project as it grows with us. The alternative is one file which is complete chaos. As for several developers working at the same time, any way of logically dividing up the app makes it easier to divide up the workstreams between developers.
Consuming a module
In ES2015, we use the import
and from
keywords to import one or several constructs like so:
import { SomeConstruct } from './module';
The imported file looks like this:
export let SomeConstruct = 5;
The basic operations involved, working with ES2015 modules, can be summarized as follows:
- Define a module and write the business logic of the module
- Export the constructs you want to make public
- Consume said module with an
import
keyword from a consumer file
Of course there is a bit more to it than that, so let's look at what else you can do in the next subsection.
An Angular example
We have been using ES2015 imports extensively throughout this chapter already, but let's emphasize when that was. As mentioned, all constructs used ES2015 modules, models, services, components, and modules. For the module, this looked like this:
import { NgModule } from '@angular/core'; @NgModule({ declarations: [], imports: [], exports: [], providers: [] }) export class FeatureModule {}
Here, we see that we import the functionality we need and we end up exporting this class, thereby making it available for other constructs to consume. It's the same thing with modules, like so:
import { Component } from '@angular/core'; @Component({ selector: 'example' }) export class ExampleComponent {}
The pipe, directive, and filter all follow the same pattern of importing what they need and exporting themselves to be included as part of an NgModule
.
Multiple exports
So far, we have only shown how to export one construct. It is possible to export multiple things from one module by adding an export
keyword next to all constructs that you wish to export, like so:
export class Math { add() {} subtract() {} } export const PI = 3.14
Essentially, for everything you want to make public you need to add an export
keyword at the start of it. There is an alternate syntax, where instead of adding an export
keyword to every construct, we can instead define within curly brackets what constructs should be exported. It looks like this:
class Math { add() {} subtract() {} } const PI = 3.14 export { Math, PI }
Whether you put export
in front of every construct or you place them all in an export {}
, then end result is the same, it's just a matter of taste which one to use. To consume constructs from this module, we would type:
import { Math, PI } from './module';
Here, we have the option of specifying what we want to import
. In the previous example, we have opted to export both Math
and PI
, but we could be content with only exporting Math
, for example; it is up to us.
The default import/export
So far, we have been very explicit with what we import and what we export. We can, however, create a so-called default export, which looks somewhat different to consume:
export default class Player { attack() {} move() {} } export const PI = 3.13;
To consume this, we can write the following:
import Player from './module'; import { PI } from './module'
Note especially the first row where we no longer use the curly brackets, {}
, to import a specific construct. We just use a name that we make up. In the second row, we have to name it correctly as PI
, but in the first row we can choose the name. The player points to what we exported as default, that is, the Player
class. As you can see, we can still use the normal curly brackets, {}
, to import specific constructs if we want to.
Renaming imports
Sometimes we may get a collision, with constructs being named the same. We could have this happening:
import { productService } from './module1/service' import { productService } from './module2/service'; // name collision
This is a situation we need to resolve. We can resolve it using the as
keyword, like so:
import { productService as m1_productService } import { productService as m2_productService }
Thanks to the as
keyword, the compiler now has no problem differentiating what is what.
The service
We started this main section talking about how ES2015 modules are for all constructs in Angular. This section is about services, and services are no different when it comes to using ES2015 modules. Services we use should be declared in a separate file. If we intend to use a service, we need to import it. It needs to be imported for different reasons though, depending on what type of service it is. Services can be of two types:
- Services without dependencies
- Services with dependencies
Service without dependencies
A service without dependencies is a service whose constructor is empty:
export Service { constructor(){} getData() {} }
To use it, you simply type:
import { Service } from './service' let service = new Service(); service.getData();
Any module that consumes this service will get their own copy of the code, with this kind of code. If you, however, want consumers to share a common instance, you change the service
module definition slightly to this:
class Service { constructor() {} getData() {} } const service = new Service(); export default service;
Here, we export an instance of the service rather than the service declaration.
Service with dependencies
A service with dependencies has dependencies in the constructor that we need help resolving. Without this resolution process, we can't create the service. Such a service may look like this:
export class Service { constructor( Logger logger: Logger, repository:Repository ) {} }
In this code, our service has two dependencies. Upon constructing a service, we need one Logger
instance and one Repository
instance. It would be entirely possible for us to find the Logger
instance and Repository
instance by typing something like this:
import { Service } from './service' import logger from './logger'; import { Repository } from './repository'; // create the service let service = new Service( logger, new Repository() )
This is absolutely possible to do. However, the code is a bit tedious to write every time I want a service instance. When you start to have 100s of classes with deep object dependencies, a DI system quickly pays off.
This is one thing a Dependency Injection library helps you with, even if it is not the main motivator behind its existence. The main motivator for a DI system is to create loose coupling between different parts of the system and rely on contracts rather than concrete implementations. Take our example with the service. There are two things a DI can help us with:
- Switch out one concrete implementation for another
- Easily test our construct
To show what I mean, let's first assume Logger
and Repository
are interfaces. Interfaces may be implemented differently by different concrete classes, like so:
import { Service } from './service' import logger from './logger'; import { Repository } from './repository'; class FileLogger implements Logger { log(message: string) { // write to a file } } class ConsoleLogger implements Logger { log(message: string) { console.log('message', message); } } // create the service let service = new Service( new FileLogger(), new Repository() )
This code shows how easy it is to switch out the implementation of Logger
by just choosing FileLogger
over ConsoleLogger
or vice versa. The test case is also made a lot easier if you only rely on dependencies coming from the outside, so that everything can therefore be mocked.