<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Bitovi Blog - UX and UI design, JavaScript and Frontend development
Loading

Angular |

3 Ways to Simplify Frontends With Multiple Microservices

Learn 3 solutions for simplifying frontend code with multiple microservices. Improve testability with Angular Resolvers, NgRX, or Backend for Frontend.

Kyle Nazario

Kyle Nazario

Twitter Reddit

Six months ago, one of our Angular Consulting clients needed help with their web app. The frontend was too complicated to test, and they weren’t sure how to fix it. Bitovi presented three approaches to help simplify the frontend code and make testing it easier.

To protect the client’s privacy, the sample code for this article will be a web app I wrote to demonstrate the problem. The sample project has separate branches for each possible solution. Like the client’s app, it’s written in Angular. However, the problem it demonstrates can happen in any frontend framework.

The Problem: Too Many Microservices

Imagine you’re working on a web app that lets users create, view, and edit invoices for their catering business. Your company has existing microservices - one for getting customer data, one for addresses, one for products, and so on. Other teams use these microservices, so you can’t change them.

When users want to create an invoice with your app, it uses this component:

// create-invoice-page.component.ts
// https://github.com/kyle-n/catering-masters/blob/main/src/app/containers/create-invoice-page/create-invoice-page.component.ts

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { map, mergeMap } from 'rxjs/operators';
import { AddressService } from 'src/app/services/address.service';
import { CustomerService } from 'src/app/services/customer.service';
import { ProductService } from 'src/app/services/product.service';
import { Address } from 'src/app/types/address';
import { Customer } from 'src/app/types/customer';
import { LineItem } from 'src/app/types/invoice';
import { Product } from 'src/app/types/product';

@Component({
  selector: 'app-create-invoice-page',
  templateUrl: './create-invoice-page.component.html',
  styleUrls: ['./create-invoice-page.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CreateInvoicePageComponent {
  protected customer$: Observable<Customer>;
  protected address$: Observable<Address>;
  protected products$: Observable<Product[]>;
  protected lineItems$: Observable<LineItem[]>;

  private customerId$: Observable<number>;

  constructor(
    customerService: CustomerService,
    addressService: AddressService,
    productService: ProductService,
    activatedRoute: ActivatedRoute
  ) {
    this.customerId$ = activatedRoute.params.pipe(
      map(params => Number(params['customerId']))
    );

    this.customer$ = this.customerId$.pipe(
      mergeMap(customerId => customerService.getCustomer(customerId))
    );
    this.address$ = this.customerId$.pipe(
      mergeMap(customerId => addressService.getAddress(customerId))
    );
    this.products$ = this.address$.pipe(
      mergeMap(address =>
        productService.getProductsAvailableAtAddress(address.id)
      )
    );
    this.lineItems$ = this.products$.pipe(
      mergeMap(products => productService.getLineItemsForProducts(products))
    );
  }
}

That’s a big constructor()! The method…

  1. Gets the customer ID
  2. Uses the customer ID to load the customer
  3. Uses the customer ID to load the customer’s address
  4. Uses the address ID to load products available at that location
  5. Uses the products to load the line items for those products

The constructor() then loads this data for the template:

<!-- create-invoice-page.component.html -->
<!-- https://github.com/kyle-n/catering-masters/blob/main/src/app/containers/create-invoice-page/create-invoice-page.component.html -->

<h1>Create invoice</h1>
<app-header
  [customerName]="(customer$ | async)?.name"
  [address]="address$ | async"
></app-header>
<section>
  <h3>New Line Items</h3>
  <app-line-item-table [lineItems]="lineItems$ | async"></app-line-item-table>
</section>
<section>
  <h3>Potential Products</h3>
  <app-product-list [products]="products$ | async"></app-product-list>
</section>
<app-submit-buttons
  [customerId]="(customer$ | async)?.id"
  [invoiceId]="1"
></app-submit-buttons>
  • constructor() needs customer$ and address$ to display the customer's name and address in the header
  • constructor() needs address$ to get products$ to display a list of possible products
  • constructor() needs products$ to get lineItems$ to display a table of line items

And yet, that constructor() is difficult to modify and impossible to test.

// create-invoice-page.component.ts
// https://github.com/kyle-n/catering-masters/blob/main/src/app/containers/create-invoice-page/create-invoice-page.component.ts

// ...

  constructor(
    customerService: CustomerService,
    addressService: AddressService,
    productService: ProductService,
    activatedRoute: ActivatedRoute
  ) {
    this.customerId$ = activatedRoute.params.pipe(
      map(params => Number(params['customerId']))
    );

    this.customer$ = this.customerId$.pipe(
      mergeMap(customerId => customerService.getCustomer(customerId))
    );
    this.address$ = this.customerId$.pipe(
      mergeMap(customerId => addressService.getAddress(customerId))
    );
    this.products$ = this.address$.pipe(
      mergeMap(address =>
        productService.getProductsAvailableAtAddress(address.id)
      )
    );
    this.lineItems$ = this.products$.pipe(
      mergeMap(products => productService.getLineItemsForProducts(products))
    );
  }

// ...

You’d have to stub so many functions to test this constructor() that the test wouldn’t do any good. It would barely resemble the real code.

You could break up the code, but how? You shouldn’t use private component methods to create the Observables. The methods would be one line long, adding indirection without encapsulating complexity.

You can’t move this logic to a service, either. The service would have to return an object containing four Observables. A service function that returns four items is not a separate or reusable function. Instead, the service function would hide the core functionality of a component inside a separate service, making it more complex and more challenging to find.

This is the problem our client faced, only with more Observables. I tried three different ways to solve it.

Solution #1: Resolvers

I first tried Angular Resolvers. Resolvers encapsulate logic required to load some piece of data. For example:

// customer.resolver.ts
// https://github.com/kyle-n/catering-masters/blob/resolvers/src/app/resolvers/customer.resolver.ts

import { inject } from '@angular/core';
import { CustomerService } from '../services/customer.service';
import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router';
import { Observable } from 'rxjs';
import { Customer } from '../types/customer';

const resolveCustomer: ResolveFn<Observable<Customer>> = (
  route: ActivatedRouteSnapshot
): Observable<Customer> => {
  const customerId = Number(route.params['customerId']);
  return inject(CustomerService).getCustomer(customerId);
};

export default resolveCustomer;

You use resolvers by adding them to the routing module…

// app-routing.module.ts
// https://github.com/kyle-n/catering-masters/blob/resolvers/src/app/app-routing.module.ts

// ...

const routes: Routes = [
  { path: '', component: HomeComponent },
  {
    path: 'customer/:customerId/invoice/new',
    component: CreateInvoicePageComponent,
    resolve: {
      customer: resolveCustomer
    }
  }
]

// ...

…and reading them in the component.

// create-invoice-page.component.ts
// https://github.com/kyle-n/catering-masters/blob/resolvers/src/app/containers/create-invoice-page/create-invoice-page.component.ts

// ...

constructor(productService: ProductService, activatedRoute: ActivatedRoute) {
    this.customer$ = activatedRoute.data.pipe(map(data => data['customer']));

//...

The customer resolver simplifies the CreateInvoicePageComponent. It lets you load customer data into the component with one line without injecting the CustomerService. You can reuse the customer resolver for other components needing customer data.

However, resolvers are a poor solution for loading data that depends on other loaded data. On the original create invoice page, you had to load address$ to load products$ to load lineItems$. Resolvers run independently and simultaneously when the user opens the route they're attached to.

You could create a resolver for lineItems$ that injects ActivatedRoute and waits for the products$ resolver, but that would add tremendous complexity. We’d have recreated the big constructor(), but across multiple files. Dependent resolvers would be hard to debug or reuse.

Solution #2: NgRx

I also tried solving the client’s problem with NgRx. NgRx and @ngrx/effects are perfect for complex data management. Instead of loading data in the create invoice component, you can dispatch an OpenedCreateInvoicePage action, load the data into the global store, and display it in the component.

// create-invoice-page.component.ts
// https://github.com/kyle-n/catering-masters/blob/ngrx/src/app/containers/create-invoice-page/create-invoice-page.component.ts

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { Address } from 'src/app/types/address';
import { Customer } from 'src/app/types/customer';
import { LineItem } from 'src/app/types/invoice';
import { Product } from 'src/app/types/product';
import { Store } from '@ngrx/store';
import { OpenedCreateInvoicePage } from 'src/app/store/actions';
import {
  selectAddress,
  selectCustomer,
  selectLineItems,
  selectProducts
} from 'src/app/store/selectors';
import { GlobalStore } from 'src/app/store/store';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-create-invoice-page',
  templateUrl: './create-invoice-page.component.html',
  styleUrls: ['./create-invoice-page.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CreateInvoicePageComponent {
  protected customer$: Observable<Customer>;
  protected address$: Observable<Address>;
  protected products$: Observable<Product[]>;
  protected lineItems$: Observable<LineItem[]>;

  constructor(
    activatedRoute: ActivatedRoute,
    private store: Store<{ globalState: GlobalStore }>
  ) {
    activatedRoute.params
      .pipe(
        map(params => ({
          customerId: Number(params['customerId']),
          invoiceId: Number(params['invoiceId'])
        })),
        takeUntilDestroyed()
      )
      .subscribe(ids => this.store.dispatch(OpenedEditInvoicePage(ids)));

    this.customer$ = this.store
      .select(selectCustomer)
      .pipe(filter((customer): customer is Customer => !!customer));
    this.address$ = this.store
      .select(selectAddress)
      .pipe(filter((address): address is Address => !!address));
    this.products$ = this.store
      .select(selectProducts)
      .pipe(filter((products): products is Product[] => !!products));
    this.lineItems$ = this.store
      .select(selectLineItems)
      .pipe(filter((lineItems): lineItems is LineItem[] => !!lineItems));
  }
}

This constructor() requires a lot of boilerplate, but is less complex than the original. It does just two things - dispatch an OpenedCreateInvoicePage action and read data from the store. The constructor knows nothing about loads, subsequent loads, or APIs; it only knows which four parts of the store to expose to its template.

Each piece of loading logic is a separate NgRx effect.

// effects.ts
// https://github.com/kyle-n/catering-masters/blob/ngrx/src/app/store/effects.ts

import { Injectable } from '@angular/core';
import { createEffect, Actions, ofType } from '@ngrx/effects';
import { catchError, from, map, of, switchMap } from 'rxjs';
import {
  GetAddress,
  GetAddressFailure,
  GetAddressSuccess,
  GetCustomer,
  GetCustomerFailure,
  GetCustomerSuccess,
  GetLineItemsFailure,
  GetLineItemsOnCreateSuccess,
  GetLineItemsOnEditSuccess,
  GetProductsFailure,
  GetProductsSuccess,
  OpenedCreateInvoicePage
} from './actions';
import { CustomerService } from '../services/customer.service';
import { AddressService } from '../services/address.service';
import { ProductService } from '../services/product.service';

@Injectable()
export class AppEffects {
  constructor(
    private readonly actions$: Actions,
    private readonly customerService: CustomerService,
    private readonly addressService: AddressService,
    private readonly productService: ProductService
  ) {}

  createPageOpened = createEffect(() => {
    return this.actions$.pipe(
      ofType(OpenedCreateInvoicePage),
      switchMap(action =>
        from([
          GetCustomer({ customerId: action.customerId }),
          GetAddress({ customerId: action.customerId })
        ])
      )
    );
  });

  getCustomer = createEffect(() => {
    return this.actions$.pipe(
      ofType(GetCustomer),
      switchMap(action =>
        this.customerService.getCustomer(action.customerId).pipe(
          map(customer => GetCustomerSuccess({ customer })),
          catchError(error => of(GetCustomerFailure({ error })))
        )
      )
    );
  });

  getAddress = createEffect(() => {
    return this.actions$.pipe(
      ofType(GetAddress),
      switchMap(action =>
        this.addressService.getAddress(action.customerId).pipe(
          map(address => GetAddressSuccess({ address })),
          catchError(error => of(GetAddressFailure({ error })))
        )
      )
    );
  });

  getProducts = createEffect(() => {
    return this.actions$.pipe(
      ofType(GetAddressSuccess),
      switchMap(action =>
        this.productService
          .getProductsAvailableAtAddress(action.address.id)
          .pipe(
            map(products => GetProductsSuccess({ products })),
            catchError(error => of(GetProductsFailure({ error })))
          )
      )
    );
  });

  getLineItemsForCreatePage = createEffect(() => {
    return this.actions$.pipe(
      ofType(GetProductsSuccess),
      switchMap(action =>
        this.productService.getLineItemsForProducts(action.products).pipe(
          map(lineItems => GetLineItemsOnCreateSuccess({ lineItems })),
          catchError(error => of(GetLineItemsFailure({ error })))
        )
      )
    );
  });
}

Testing effects is straightforward. When one action comes in, something else should happen in response.

// effects.spec.ts
// https://github.com/kyle-n/catering-masters/blob/ngrx/src/app/store/effects.spec.ts

// ...

  it('should start loading customer and address when create page is opened', done => {
    service.createPageOpened
      .pipe(
        take(2),
        reduce((acc, action) => [...acc, action], [] as Action[])
      )
      .subscribe({
        next: actions => {
          expect(actions).toEqual([
            GetCustomer({ customerId: mockCustomer.id }),
            GetAddress({ customerId: mockCustomer.id })
          ]);
        },
        complete: done
      });

    mockActions$.next(OpenedCreateInvoicePage({ customerId: mockCustomer.id }));
  });
  
// ...

NgRx also simplifies combining data. You can keep the customer, address, products and line items in the store and combine them using selectors. Selectors, all pure functions, are testable.

NgRx, however, has three disadvantages.

  1. NgRx adds a significant amount of code and complexity. Not every app needs industrial-grade state management. Adopt it only if your app needs it.

  2. The chain of subsequent loads is hard to follow. You can mitigate this through comments or keeping related effects in one file, but it is undeniably less clear than putting all the logic in one constructor().

  3. NgRx can be hard to learn. Your team may not want to spend time training on reducers and sub-reducers, and pure functions and effects.

Overall, though, the separation of concerns and ease of testing make NgRx a great option for loading data from many microservices.

Solution #3: Backend for Frontend

One more potential solution is to create another server. This server will sit between the frontend and existing microservices. It will talk to them for the frontend, process the data, and return only what the client needs. This is the backend for frontend pattern.

The BFF is tightly coupled to a specific user experience, and will typically be maintained by the same team as the user interface, thereby making it easier to define and adapt the API as the UI requires, while also simplifying process of lining up release of both the client and server components.

Since the frontend team will maintain a BFF, you should write it in TypeScript. You should also include it in the frontend repository because it will be tightly coupled to the UI. Lastly, since the frontend is in Angular, your backend could use “Angular for the server,” NestJS.

( Angular Universal would also work, but Nest has more features. My client’s app also relied on libraries incompatible with server-side rendering , which Angular Universal requires).

You’ll create a Nest app doing two things: serving your Angular app and taking API requests from it. Angular will know nothing about any microservice. It will know only what Nest returns.

In the previous two solutions, you used different methods to load the same data. With a BFF, you need only load what is shown in the UI.

Consider the header component. At first, it used two pieces of data: address and customerName.

// header.component.ts
// https://github.com/kyle-n/catering-masters/blob/main/src/app/components/header/header.component.ts

import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Address } from 'src/app/types/address';

@Component({
  selector: 'app-header',
  templateUrl: './header.component.html',
  styleUrls: ['./header.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeaderComponent {
  @Input() address: Address | null | undefined;
  @Input() customerName: string | null | undefined;
}
<!-- header.component.html -->
<!-- https://github.com/kyle-n/catering-masters/blob/main/src/app/components/header/header.component.html -->

<h2>{{ customerName }}</h2>
<div *ngIf="address">
  {{ address.street }}
  {{ address.city }}
</div>

To get this data, the app loaded address$ and customer$ in the parent component:

// create-invoice-page.component.ts
// https://github.com/kyle-n/catering-masters/blob/main/src/app/containers/create-invoice-page/create-invoice-page.component.ts

//...

    this.customer$ = this.customerId$.pipe(
      mergeMap(customerId => customerService.getCustomer(customerId))
    );
    this.address$ = this.customerId$.pipe(
      mergeMap(customerId => addressService.getAddress(customerId))
    );

//...

The customer object returned from getCustomer() has these properties:

// customer.ts
// https://github.com/kyle-n/catering-masters/blob/main/src/app/types/customer.ts

export type Customer = {
  id: number;
  name: string;
  createdAt: string;
  updatedAt: string;
  lastInvoiceId: number;
  lastInvoiceDate: string;
  ownerName: string;
  ownerEmail: string;
};

The header component, though, only requires the customer’s name, street, and city. You can use your NestJS application to return just this data.

First, make a top-level folder called shared containing types.ts.

// types.ts
// https://github.com/kyle-n/catering-masters/blob/nestjs/shared/types.ts

export type InvoiceHeaderCustomerData = {
  name: string;
  street: string;
  city: string;
}

Next, replace your Angular services for loading customer, address, and product data with Nest services. The customer microservice gets its own Nest service, and the address gets its own, and so on.

Then, create a new Nest API endpoint specifically for loading header data.

// app.controller.ts
// https://github.com/kyle-n/catering-masters/blob/nestjs/server/src/controllers/app.controller.ts

import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
import { InvoiceHeaderCustomerData } from '@shared/types';
import { Observable, forkJoin } from 'rxjs';
import { map } from 'rxjs/operators'
import { mapCustomerToInvoiceHeaderCustomerData } from 'src/mappers/invoice-header-customer-data.mapper';
import { AddressService } from 'src/services/address.service';
import { CustomerService } from 'src/services/customer.service';

@Controller()
export class AppController {
  constructor(
    private readonly customerService: CustomerService,
    private readonly addressService: AddressService
  ) {}

  @Get('customers/:customerId/header-data')
  getCustomerHeaderData(
    @Param('customerId', ParseIntPipe) customerId: number
  ): Observable<InvoiceHeaderCustomerData> {
    const customer$ = this.customerService.getCustomer(customerId);
    const address$ = this.addressService.getAddress(customerId);
    return forkJoin(customer$, address$).pipe(
      map(([customer, address]) =>
        mapCustomerToInvoiceHeaderCustomerData(customer, address)
      )
    );
  }
}

mapCustomerToInvoiceHeaderCustomerData is a pure function that returns the header data:

// invoice-header-customer-data.mapper.ts
// https://github.com/kyle-n/catering-masters/blob/nestjs/server/src/mappers/invoice-header-customer-data.mapper.ts

import { InvoiceHeaderCustomerData } from '@shared/types';
import { Address } from 'src/types/address';
import { Customer } from 'src/types/customer';

export function mapCustomerToInvoiceHeaderCustomerData(
  customer: Customer,
  address: Address
): InvoiceHeaderCustomerData {
  return {
    name: customer.name,
    street: address.street,
    city: address.city
  };
}

You can call this endpoint from Angular…

// api.service.ts
// https://github.com/kyle-n/catering-masters/blob/nestjs/src/app/services/api.service.ts

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { InvoiceHeaderCustomerData } from '@shared/types';

@Injectable({
  providedIn: 'root'
})
export class ApiService {
  private readonly baseUrl = '/api';

  constructor(private readonly http: HttpClient) {}

  getCustomerHeaderData(
    customerId: number
  ): Observable<InvoiceHeaderCustomerData> {
    return this.http.get<InvoiceHeaderCustomerData>(
      `${this.baseUrl}/customers/${customerId}/header-data`
    );
  }
}

…and use it in CreateInvoicePageComponent

// create-invoice-page.component.ts
// https://github.com/kyle-n/catering-masters/blob/nestjs/src/app/containers/create-invoice-page/create-invoice-page.component.ts

//...

  constructor(apiService: ApiService, activatedRoute: ActivatedRoute) {
    this.customerId$ = activatedRoute.params.pipe(
      map(params => Number(params['customerId']))
    );

    this.headerData$ = this.customerId$.pipe(
      mergeMap(customerId => apiService.getCustomerHeaderData(customerId))
    );
  }

//...

…and the header component.

// header.component.ts
// https://github.com/kyle-n/catering-masters/blob/nestjs/src/app/components/header/header.component.ts

import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { InvoiceHeaderCustomerData } from '@shared/types';

@Component({
  selector: 'app-header',
  templateUrl: './header.component.html',
  styleUrls: ['./header.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeaderComponent {
  @Input() customerData: InvoiceHeaderCustomerData | null | undefined;
}
<!-- header.component.ts -->
<!-- https://github.com/kyle-n/catering-masters/blob/nestjs/src/app/components/header/header.component.ts -->

<ng-container *ngIf="customerData">
  <h2>{{ customerData.name }}</h2>
  <div>
{{ customerData.street }}
{{ customerData.city }}
  </div>
</ng-container>

You can repeat this process for every part of the page. Products, line items, everything is returned from Nest. The frontend doesn’t know loading line items requires loading an address and products for that address.

// app.controller.ts
// https://github.com/kyle-n/catering-masters/blob/nestjs/server/src/controllers/app.controller.ts

// ...

  @Get('invoices/line-items')
  getLineItemsForNewInvoice(
    @Query('customerId', ParseIntPipe) customerId: number
  ): Observable<LineItem[]> {
    const address$ = this.addressService.getAddress(customerId);
    return address$.pipe(
      mergeMap(address =>
        this.productService.getProductsAvailableAtAddress(address.id)
      ),
      mergeMap(products =>
        this.productService.getLineItemsForProducts(products)
      )
    );
  }

// ...

This approach has several advantages:

  1. The UI code is dead simple. It strips whole categories of complexity out of the frontend. Every piece of content on the page is one network request.
  2. It allows the frontend to focus on pure UI matters. Forms, buttons, routing, navigation, things like that. No business logic.
  3. It allows code reuse. This is especially useful for sharing validators across client and server.
  4. This approach helps performance. NestJS automatically caches responses to incoming requests. If the user refreshes the create invoice page and does a second GET header data, Nest will return the cached response. This is useful for lightening the load on your existing APIs.

If your Nest app sends many outgoing requests to your microservices, Nest can also cache responses from those microservices.

There is one downside to this approach - it adds more layers to the app. The additional layers may require infrastructure changes, and it might be overkill in some situations. Most websites don’t need an intermediary to collect data from multiple microservices.

But since the catering app does need that, it’s a good call.

The Best Solution: Backend for Frontend (BFF)

I presented the three prototypes and my recommendations to the client: Reducers, NgRX, and BFF.

Reducers were the wrong choice. They work if all your data loads are independent. Unfortunately, the client’s were not, and chained resolvers would be difficult to maintain.

NgRx, in my opinion, was a great option. It added complexity but made testing easy. It is good at loading and combining data.

The BFF pattern, though, was my favorite. It was like one giant adapter, providing an ergonomic, UI-friendly API for the existing microservices. It also made testing easier.

After presenting all three options, the client decided to go with the BFF pattern.

Need more guidance?

These problems are tough. A lot can vary, and the BFF pattern may not be right for your application. To get the best advice, get in touch with our team of experienced frontend development consultants. We’ve seen every kind of app and would be happy to find a great solution for you.

Our Community Discord is a great starting place to seek help. We’re always available to answer your questions, and our community might have some ideas, too. Come hang out with us!

Join our Discord