jmeinlschmidt

Power of Dependency Injection: Logging Across Different Environments

Introduction

When it comes to building robust and scalable front-end applications, Angular stands out with one of its most powerful features: built-in Dependency Injection (DI). Unlike other frameworks and libraries, Angular’s DI is not just an add-on but a core part of its architecture. This feature unlocks the potential for cleaner, more maintainable code by enabling developers to easily implement Dependency Inversion — a key principle of Clean Architecture. This principle generally states that higher-level logic should not depend on lower-level logic.

A simplified example of this could be a class (or function) that doesn’t concern itself with where or how its dependencies are obtained. Instead, it delegates this responsibility to someone else and simply states what it needs to function, hence the term dependency inversion.

By leveraging polymorphism alongside DI, we can programmatically configure these dependencies based on the runtime environment, making it possible to neatly implement comprehensive Software Configuration Management. In larger applications, it’s common to encounter at least several dozen custom providers that help configure the application, particularly based on the environment (staging, production, different customers, etc.). The significance of Angular’s DI is further highlighted by the Angular Team’s ongoing efforts to enhance the tools for working with it, such as DevTools.

The main focus of this article is to showcase various DI techniques in practice, rather than to demonstrate how to build the perfect logging system.

Defining the Problem

Today’s goal is to implement logging using DI in such a way that the individual components and services (higher-level code) don’t need to worry about where exactly errors are being logged. In other words, we’re not concerned with the lower-level logic. Let’s start by defining an interface to keep things more abstract:

interface Logger {
  error: (message: string) => void;
}

Simple Logger

In this section, we’ll demonstrate a basic example of using DI, which we will build upon in the following sections. Let’s define a concrete logger, such as simple console logging, that adheres to our previously defined Logger interface.

@Injectable()
class ConsoleLogger implements Logger {
  public error(message: string): void {
    console.error(error);
  }
}

Next, we’ll configure DI so that when a dependency for Logger is requested, it provides the specific implementation, ConsoleLogger:

bootstrapApplication(AppComponent, {
  providers: [{ provide: Logger, useClass: ConsoleLogger }],
});

However, we’ll encounter an issue at this point. The DI token, which is the Logger interface, needs to be a class type because interfaces don’t exist at runtime. It’s a technical detail that can sometimes be overlooked. The usage in ConsoleLogger remains the same, though.

abstract class Logger {
  public abstract error(message: string): void;
}

Next, we can request this dependency:

@Component({ ... })
class MyComponent {
  // Mind that we are requesting abstract Logger, not any specific implementation.
  private readonly logger = inject(Logger);

  public somethingWrong(): void {
    this.logger.error('Something went wrong');
  }
}

Multiple Loggers

Now, nothing prevents us from having multiple concrete implementations of the logger. In the following implementation, we’ll display the error directly to the user:

@Injectable()
class HumanFriendlyLogger implements Logger {
  private readonly snackbar = inject(MatSnackBar);

  public error(message: string): void {
    // Display the error to the user using a Snackbar from Angular Material
    this.snackbar.open(message);
  }
}

In a production environment, however, it’s highly conveniet to use one of the existing logging management services, such as Firebase or Sentry:

@Injectable()
class RemoteLogger implements Logger {
  public error(message: string): void {
    // Log for example to Firebase, Sentry, ...
  }
}

By leveraging the principles of DI, we can provide different implementations of the Logger contract without the higher-level logic of the application (such as components and services) needing to be aware of them at all.

bootstrapApplication(AppComponent, {
  providers: [{ provide: Logger, useClass: RemoteLogger }],
});

Now we face a new challenge: during local development, we don’t want to log unnecessarily to Sentry, but we do want to use it in production. Therefore, we need to modify the provider mentioned above depending on the environment.

In a B2B environment, we often encounter situations where our customers, for various reasons, cannot allow logging to third-party services like Firebase or Sentry. For these customers, we need the capability to disable such functionality and log in an alternative manner. We’ll explore this technique in the next section.

Concrete Implementation Based on Environment

Let’s consider the following three environments: local, stage, and production. Each of these environments has its own environment.ts file, which is provided to Angular CLI during the build process of the application.

// Example of the production environment.ts file

export const environment = {
  label: 'production';
  // More environment config ...
}

Let’s establish the following rules for which logger to provide depending on the environment:

  • productionRemoteLogger
  • stageHumanFriendlyLogger
  • localConsoleLogger
classDiagram Logger <|-- RemoteLogger Logger <|-- HumanFriendlyLogger Logger <|-- ConsoleLogger Logger: +error()* class RemoteLogger{ +error() } class HumanFriendlyLogger{ +error() } class ConsoleLogger{ +error() }

Now, let’s translate this list into code:

const loggerEnvironmentMapper: Record<string, Type<Logger>> = {
 production: RemoteLogger,
 stage: HumanFriendlyLogger,
 local: ConsoleLogger,
};

For educational purposes, the example is, of course, hypothetical. Moreover, instead of using a general string, it would be better to use a string literal type for improved type safety. However, for simplicity, we’ll omit that in this case.

To initialize a specific class, we’ll use the Injector class from the @angular/core library. Essentially, this is nothing new; we’re still leveraging the same functionality that bootstrapApplication handled for us previously. We can achieve the same functionality by manually creating our own Injector:

const myInjector = Injector.create({
  providers: [{ provide: Logger, useClass: RemoteLogger }],
});

You can obtain the Logger dependency using the following call:

myInjector.get(Logger);

Since our specific Logger implementations might have their own dependencies that need to be resolved, it’s a good practice to provide a Parent Injector. Remember that this code must be executed within an Injection Context.

Here’s how the complete code might look:

const getLoggerByEnvironment<T extends { label: string }>(): Logger => {
  const { label } = environment as T; // Import environment file
  const injector = inject(Injector);
  const loggerEnvironmentMapper: Record<string, Type<Logger>> = {
    production: RemoteLogger,
    stage: HumanFriendlyLogger,
    local: ConsoleLogger,
  };

  const defaultLogger = ConsoleLogger;
  const concreteLogger: Type<Logger> = loggerEnvironmentMapper[label] ?? defaultLogger;
  const providers: Provider[] = [{ provide: Logger, useClass: concreteLogger }];

  return Injector.create({ providers, parent: injector }).get(Logger);
}

The example is once again simplified for educational purposes. In practice, it’s common to see the environment itself provided through DI as well.

In the previous DI configuration, we used the useClass property, which provides and initializes a given class. Now, we will use useFactory instead. This approach allows for more dynamic and flexible dependency resolution.

Here’s how you can modify the code to use useFactory:

bootstrapApplication(AppComponent, {
  providers: [{ provide: Logger, useFactory: getLoggerByEnvironment }],
});

Note that because we are using inject(), a form of the Service Locator (anti)pattern, instead of constructor-based DI, it is not necessary to specify deps when using useFactory.

Logger Composition

Let’s get a bit creative: Suppose that in production we also want to display errors to the user using HumanFriendlyLogger, and at the same time, we want to log to the console in all environments. Our logic would then change as follows:

  • productionRemoteLogger, ConsoleLogger a HumanFriendlyLogger
  • stageHumanFriendlyLogger a ConsoleLogger
  • localConsoleLogger

A reasonable approach for this configuration is to use DI once again. Let’s start by designing a class that can combine multiple Logger instances together. Unsurprisingly, this class will also implement the Logger interface:

export class MultiLogger implements Logger {
  private loggers: Logger[] = [];

  // Mind the spread operator, as constructor dependencies cannot be passed directly as an array
  constructor(...loggers: Logger[]) {
    this.loggers = loggers;
  }

  public error(message: string | Error): void {
    this.loggers.map((logger) => logger.error(message));
  }
}
classDiagram Logger <|-- RemoteLogger Logger <|-- HumanFriendlyLogger Logger <|-- ConsoleLogger Logger <|-- MultiLogger MultiLogger --> Logger:accepts many Logger: +error()* class RemoteLogger{ +error() } class HumanFriendlyLogger{ +error() } class ConsoleLogger{ +error() } class MultiLogger{ +error() }

Let’s modify our function to obtain an instance of the Logger:

const getLoggerByEnvironment<T extends { label: string }>(): Logger => {
  const { label } = environment as T; // Import environment file
  const injector = inject(Injector);

  // Let's provide an array of Loggers
  const loggerEnvironmentMapper: Record<string, Type<Logger>[]> = {
    production: [RemoteLogger, ConsoleLogger, HumanFriendlyLogger],
    stage: [HumanFriendlyLogger, ConsoleLogger],
    local: [ConsoleLogger],
  };

  const defaultLogger = [ConsoleLogger];
  const concreteLoggers: Type<any>[] = loggerEnvironmentMapper[label] ?? defaultLogger;
  const providers: Provider[] = [
    ...concreteLoggers, // Instantiate individual concrete loggers
    // Mind the spread operator, as constructor dependencies cannot be passed directly as an array
    {
      provide: Logger,
      useFactory: (...loggers: Logger[]) => new MultiLogger(...loggers),
      deps: concreteLoggers,
    },
  ];

  return Injector.create({ providers, parent: injector }).get(Logger);
}

In the production environment, we now log using all three loggers simultaneously — Sentry/Firebase, the console, and displaying errors to the user.

Conclusion

Do you remember when we initially defined a component that uses our logging?

@Component({ ... })
class MyComponent {
  // Mind that we are requesting abstract Logger, not any specific implementation.
  private readonly logger = inject(Logger);

  public somethingWrong(): void {
    this.logger.error('Something went wrong');
  }
}

Throughout this process, the component that uses our logging hasn’t changed. It hasn’t changed precisely because the higher-level logic doesn’t depend on the lower-level logic. We were able to achieve this thanks to DI.

The purpose of this article was to introduce some key concepts and, most importantly, to demonstrate the application of certain DI principles in configuration management across different environments.