Async Factories in Angular

Async Factories in Angular

I've encountered a problem these days related to some limitations that Angular has when it comes to dependency injection and I want to share with you the solution that I've found.

I will explain the problem, the solution and in the end, let's have an example that is representative of our case. Shall we?

What's the problem?

We had a component that grew a lot and with all the growth came the pain. Tons of code, hard to change, coupled, with more than one person working on the file, eagerly-loaded code. You name it.

You had all the ingredients of what could go wrong.

Our target was to decouple logic that changes and add the flexibility to change without having to modify the current code. Just extending it. This way, in case more than one person is working in the same area, it shouldn't have to spend too much time solving conflicts.

Solution?

We've decided to go with Factory Pattern. Through all the analysis, discussions and POC, we considered all the trade-offs and it seemed to be the way to go in our case

The gains:

  • Decoupling - Specific logic should be separated from others

  • Single Responsibility - Going from big classes to smaller services narrows down the responsibility of the classes

  • Open to extension, closed to modification - Instead of changing the code and risk affecting much more code that you're not aware of, it should make it easier to create new code without risking anything

  • A framework to go by - If you know the pattern, it's very easy to add new features and ramp up yourself on the project

The cons:

  • Boilerplate code - Some code repeats on and on that you can't get rid of it

  • Contract change requires a lot of files to change - If you change the contract it might be quite a task to change. Not hard, but repetitive.

A quick recap of what the Factory pattern looks like.

You have a component where you use a factory that handles what service to be instantiated based on some logic (usually a type).

A cool thing about Angular is the mechanism of Dependency Injection out-of-the-box. When you're declaring a provider for your service, besides the class, you can also provide a factory to inject a certain service based on some conditions.

The implementation came easily with that. I'll provide a mock example of what we did.

Let's say we have a parser page that has radio buttons for what type of format you want to parse. In our example, we have CSV and XML. The structure of the services is the same, they should do the same thing but under the hood, implementation differs completely.

Each time you change the type of the file format, the service will be created once again.

For simplicity's sake, we will not bother about how we get the parser type, let's say we just know how to get it

Component:

@Component({
  selector: 'app-parser-page',
  standalone: true,
  imports: [CommonModule],
  providers: [
    { provide: 'PARSER_TYPE', useValue: getParserType() },
    {
      provide: BaseParserService,
      useFactory: (PARSER_TYPE: ParserType) => parserFactory(PARSER_TYPE),
      deps: ['PARSER_TYPE']}
  ],
  templateUrl: './parser-page.component.html',
})
export class ParserPageComponent {

  constructor(readonly parserService: BaseParserService) {
  }

  text = '';
  parsedText = '';

  parse(): void {
    this.parsedText = this.parserService.parse(this.text)
  }

}

Factory:

export const parserFactory = (type: ParserType) => {
  switch (type) {
    case ParserType.XML:
      return new XmlParserService();
    case ParserType.CSV:
      return new CsvParserService();
    default:
      throw new Error("Unknown parser type");
  }
}

Base Service:

export abstract class BaseParserService {
  abstract type: ParserType;
  abstract parse(text: string): string
}

One of the concrete classes:

@Injectable({
  providedIn: 'root'
})
export class XmlParserService implements BaseParserService {

  readonly type = ParserType.XML;

  parse(text: string): string {
    return `${text} parsed to XML`;
  }

}

This way, if we will need to add another parser, we will not change the parser-page. Instead, we will add a new case to the factory and provide the concrete implementation of that type of file format.

So far, so good. We are decoupled!

But we are too eager! Let's get lazy!

Problem again. What's the solution here?

We might have gotten rid of coupled code, and we solved a big problem that we had but we haven't solved anything in the area of eagerly-loaded code.

We are still loading lots of code that we don't need.

Unfortunately, we had to ditch the useFactory and the use of Angular's DI in this case because we couldn't lazy-load the dependencies we need. We had to load all the services that the factory can create and it wasn't something that we needed.

So, instead of using useFactory, we've decided to create the service manually, not relying on DI.

How do we do that? Let's follow some code snippets.

First of all, we moved from useFactory to instantiation through the factory

@Component({
  selector: 'app-parser-page',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './parser-page.component.html',
})
export class ParserPageComponent {
  protected text = '';
  protected parsedText = '';

  protected parserService!: BaseParserService;

  async ngOnInit() {
    await changeParser(getParserType())
  }

  async changeParser(type: ParserType): Promise<void> {
    this.parserService = await parserFactory(type);
    this.text = ''
    this.parsedText = '';
  }

  parse(): void {
    this.parsedText = this.parserService.parse(this.text)
  }

}

Second, instead of importing all the files and returning the correct service based on the type, import the file only when you know what you need.

Using async/await here as we are dependent to file read

export const parserFactory = async (type: ParserType) => {
  switch (type) {
    case ParserType.XML:
      return new (await import('../services/xml-parser.service')).XmlParserService();
    case ParserType.CSV:
      return new (await import('../services/csv-parser.service')).CsvParserService();
    default:
      throw new Error("Unknown parser type");
  }
}

Third, analyze the network tab to check where the load of the service is done and check the number of bundles imported.

Investigating the network we can see the followings:

  • XML service and CSV service are two different js bundles

  • Looking at the Waterfall, we can see that CSV was loaded only at the time when we clicked on CSV.

Conclusion

In the end, it was a solution that boosted our productivity and helped us build features faster than before in a more isolated and safe manner.

At the end of the day, we took the way that was more convenient for us and also scalable for the business needs and gave us some space to maneuver the changes that we encounter.

If you want to explore more the example, you can find below a CodeSandbox snippet.