Environment-based substitution in Angular 4

6 min read

In this post we’ll cover the necessary knowledge to implement environment-based substitution.

What do we mean by environment-based substitution?

Environment-based substitution is being able to dynamically change the objects used by an application, based on the environment, using Angular’s dependency injection feature.

We will begin by learning a few things about Angular's Dependency Injection, followed up by the basic concepts of Angular's Environment Management.

By the end of this post, you will have all the tools you need to implement a solution that runs with a specific stack of services in development mode, and a different one in production.

Here is the code on github.

The Goal

In order to learn Angular’s Environment-based substitution, we’ll use the following test cases, and try to make them all pass without using ifs. (because real devs don’t use ifs)

import {environment} from '../environments/environment'; // => The default environment will be used here (i.e. development)

describe('UserComponent in dev mode', () => {

  let userComponent: UserComponent;
  let fixture: ComponentFixture<UserComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({ /* Test configuration */ });
    fixture = TestBed.createComponent(UserComponent);
    userComponent = fixture.componentInstance; // UserComponent test instance
  });

  it('uses InMemoryUserService on dev', () => {
    expect(userComponent.userService.areYouFake()).toBe(true);
  });
});
import {environment} from '../environments/environment.prod'; // => Explicitly import prod environment

describe('UserComponent in prod mode', () => {

  let userComponent: UserComponent;
  let fixture: ComponentFixture<UserComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({ /* Test configuration */ });
    fixture = TestBed.createComponent(UserComponent);
    userComponent = fixture.componentInstance; // UserComponent test instance
  });

  it('uses HttpUserService on prod', () => {
    expect(userComponent.userService.areYouFake()).toBe(false);
  });
});

Angular's DI

Dependency Injection is a common and powerful way to manage code dependencies.

The way Angular approaches DI is by using ‘providers’, which are declared under the providers section of a Component, or in the AppModule.

A provider is something that can create or deliver a service.
Angular creates a service instance from a class provider using the new keyword.

When a service is created using a provider, it is registered in the providers section of the AppModule, and will be available throughout the application.
It’s worth mentioning that when services are created this way, the get created only once, so each one of them is a singleton.

Let's see an example:

/** src/app/app.module.ts **/

@NgModule({
  declarations: [
    AppComponent,
    UserComponent
  ],
  imports: [
    BrowserModule,
    HttpClientModule
  ],
  providers: [UserService], /* => Now UserService can be injected in any constructor in the app */
  bootstrap: [AppComponent]
})
export class AppModule { }
/** src/services/user.service.ts **/

@Injectable()
export class UserService {
  private usersUrl = 'api/users';

  constructor(private http: HttpClient) { }

  getUser(id: number = 1): Observable<User> {
    const url = `${this.usersUrl}/${id}`;
    return this.http.get<User>(url)
      .pipe(
        tap(_ => console.log(`fetched user id=${id}`)),
        catchError(this.handleError<User>(`getUser id=${id}`))
    );
  }

  private handleError<T> (operation = 'operation', result?: T) {
    return (error: any): Observable<T> => {
      console.log(`${operation} failed: ${error.message}`);
      return of(result as T);
    };
  }
}
/** src/components/user.component.ts **/

@Component({
  selector: 'app-user',
  templateUrl: './user.component.html',
  styleUrls: ['./user.component.css']
})
export class UserComponent implements OnInit {
  private user: User;
  private userName: string;

  constructor(private userService: UserService) {}  /* => Here is where the injection happens */

  ngOnInit() {
    this.userService.getUser(1)
      .subscribe(user => {
        this.user = user;
        this.userName = user.userName;
      });
  }
}

Suppose that we have many instances of a particular component, and we need to have an instance of a certain service for each component, instead of a singleton. We can achieve this by adding the service in the providers section of the component, and removing it from the providers section of the AppModule.

This way, we’ll have one instance of the provided service for each instance of the component.

Here’s the code:

/** src/app/app.module.ts **/

@NgModule({
  declarations: [
    AppComponent,
    UserComponent
  ],
  imports: [
    BrowserModule,
    HttpClientModule
  ],
  providers: [/* => Notice that this is empty */], 
  bootstrap: [AppComponent]
})
export class AppModule { }
/** src/components/user.component.ts **/

@Component({
  selector: 'app-user',
  templateUrl: './user.component.html',
  styleUrls: ['./user.component.css'],
  providers: [UserService] /* => Now each UserComponent will have its own instance of UserService */
})
export class UserComponent implements OnInit {
  private user: User;
  private userName: string;

  constructor(private userService: UserService) {}

  ngOnInit() {
    this.userService.getUser(1)
      .subscribe(user => {
        this.user = user;
        this.userName = user.userName;
      });
  }
}

Defining Providers

The simplest way to register a provider is by adding it to the list of providers:

providers: [UserService]

But there are other ways to do this:

The value provider

We can pass a value provider with the useValue property to a provided service or object, in order to control the runtime configuration over it.
This can be useful when writing a unit test, to replace a production service with a fake/mock for example.

{ provide: USERS_URL,  useValue: 'api/users' }

Now we are able to do the following in any object that use the USERS_URL:

constructor(@Inject('USERS_URL') private usersUrl) { }

The class provider

Just as we can provide runtime configuration to determine the value of a provided object or service, we can provide runtime configuration for changing an object’s class.
This way we can switch between a default class and an alternative implementation depending on the environment, enabling us to implement a different strategy, extend the default class, or fake the behavior of the real class in a test case, etc.

Let's suppose we wanted an instance of UserService that didn’t use the HttpClient to make requests. We could use the useClass provider to change the class of the instantiated object when a new service is created:

{ provide: UserService, useClass: InMemoryUserService }

The InMemoryUserService would have the same interface as UserService, but a different implementation (for example it could iterate over a list of users instead of retrieving them from a database).

For this to work, and for the sake of keeping our design clean, we will need a UserService interface and the InMemoryUserService implementation.

Let the refactor beggin!

/** src/services/user.service.ts **/

export interface UserService {

  getUser(id: number): Observable<User>;
}
/** src/services/in-memory-user.service.ts **/

@Injectable()
export class InMemoryUserService implements UserService {

  getUser(id: number): Observable<User> {
    return of(USERS[id]);
  }
}
/** src/services/http-user.service.ts  **/

@Injectable()
export class HttpUserService implements UserService {
  constructor(private http: HttpClient, @Inject('USERS_URL') private usersUrl) { }

  getUser(id: number = 1): Observable<User> {
    const url = `${this.usersUrl}/${id}`;
    return this.http.get<User>(url)
      .pipe(
        tap(_ => console.log(`fetched user id=${id}`)),
        catchError(this.handleError<User>(`getUser id=${id}`))
    );
  }

  private handleError<T> (operation = 'operation', result?: T) {
    return (error: any): Observable<T> => {
      console.log(`${operation} failed: ${error.message}`);
      return of(result as T);
    };
  }
}

To keep things simple, we’ll rename UserService to HttpUserService.
Please keep in mind that this is a bad name because it is implementative: it refers to how the service was implemented, which is something we want the service to encapsulate and hide from us.
In a real app this service could, for example, get users from Facebook. So a better name would be FacebookUserService.

/** src/components/user.component.ts **/

@Component({
  selector: 'app-user',
  templateUrl: './user.component.html',
  styleUrls: ['./user.component.css'],
  providers: [{ provide: UserService, useClass: InMemoryUserService }] /* => Now UserComponent use InMemoryUserService */
})
export class UserComponent implements OnInit {
  private user: User;
  private userName: string;

  constructor(private userService: UserService) {}

  ngOnInit() {
    this.userService.getUser(1)
      .subscribe(user => {
        this.user = user;
        this.userName = user.userName;
      });
  }
}

So now we know how providers work, and some of the ways in which we can use them.

The drawback of this implementation is that we hardcoded the value InMemoryUserService, and we would like it to change automatically, depending on the context.

So let's see what we can do to make it even better.

Angular's Environments

Angular comes with two default environments out of the box: development and production.
The configuration files for each of them can be found under the src/environments/ folder, with the name of environment.ts and environment.prod.ts respectively.
This is where we should configure the API URLs for dev and prod, and the mocks for our objects.

To continue with our example, let’s try adding the different implementations of the UserService class for our UserComponent, in dev and in production.

/* src/environments/environment.ts */
export const environment = {
    production: false,
    userServiceType: InMemoryUserService
};

/* src/environments/environment.prod.ts */
export const environment = {
    production: true,
    userServiceType: HttpUserService
};

Ok.. but this is not enough.

Now we have to tell to the UserComponent to use this customization.

/** src/app/app.module.ts **/

@NgModule({
  declarations: [
    AppComponent,
    UserComponent
  ],
  imports: [
    BrowserModule,
    HttpClientModule,
  ],
  /* => We want to have only one instance of my UserService in the entire app, so we need to move the registration back to the AppModule. */
  providers: [{provide: 'MyUserService', useClass: environment.userServiceType}], /* => Notice how we are dynamically generating the UserService concrete class depending on the environment. */
  bootstrap: [AppComponent]
})
export class AppModule { }
/** src/components/user.component.ts **/

@Component({
  selector: 'app-user',
  templateUrl: './user.component.html',
  styleUrls: ['./user.component.css']
})
export class UserComponent implements OnInit {

  private user: User;
  private userName: string;

  constructor(@Inject('MyUserService') private userService: UserService) {} /* => The final step is to add the @Inject decorator to the constructor parameter */

  ngOnInit() {
    this.userService.getUser(1)
      .subscribe(user => {
        this.user = user;
        this.userName = user.userName;
      });
  }
}

For simplicity we could say that in the AppModule we are saving the concrete UserService generated object into MyUserService (which could also be called UserService but I wanted to explicitly differentiate it from it’s interface) and then injecting it on UserComponent using the @Inject decorator.
If the @Inject decorator was not present, then Angular's DI would use the type of the parameter by default.

Now our tests are green and if we run our app using ng serve --open, the dev environment will be enabled, and an instance of InMemoryUserService will be injected into the UserComponent.

If we use ng serve --open with the flag --env=prod instead, then HttpUserService will be injected.

That's all! Simple, isn't it?


Recibí nuestra Newsletter!

¿Querés estar al tanto de todo lo que pasa en 10Pines?
* Campo requerido

¿Qué te gustaría recibir?