Modern and Lightweight Angular Architectures With Angular’s Latest Innovations

Angular has received several new features that make it possible to use the framework in a fresh and lightweight way.

In the last time, Angular has received several new features that make it possible to use the framework in a fresh and lightweight way. This article series shows how these innovations help make our solutions more maintainable.

The examples used can be found under Example.

Standalone Components

I start with what is probably the biggest innovation of the last releases: the long-awaited Standalone Components. Since they do not need NgModules, there are no unneeded indirection anymore. This makes the application a more lightweight. To tell Angular that a Component is standalone, set the standalone flag to true:

@Component({
  standalone: true,
  imports: [
    NgIf,
    NgForOf,
    AsyncPipe,
    JsonPipe,

    FormsModule, 
    FlightCardComponent,
    CityValidator,
  ],
  selector: 'flight-search',
  templateUrl: './flight-search.component.html'
})
export class FlightSearchComponent {
    private store = inject(Store);
    readonly flights$ = this.store.select(selectFilteredFlights);

    […]
}

In addition, it is now necessary to provide the compilation context via the new imports array. This includes all other components, but also directives and pipes that are used in the template of the Standalone Component. Existing NgModules can also be imported. This is necessary to continue supporting existing code. The Angular team thus avoids breaking changes.

Also, even the building blocks supplied with Angular are not yet completely free of NgModules. The example shown partially reflects this: While the components of the CommonModule, including NgIf, NgFor, AsyncPipe, and JsonPipe are now standalone, this is not yet the case with the FormsModule.

The previous example also shows another innovation that makes Angular solutions lighter: Dependencies can now be injected directly into properties using inject. The component does not have to introduce its own constructor for this.

More details about Standalone Components can be found here.

inject and the EcmaScript Standard

Using inject also has another not-so-obvious benefit: the injected dependency can provide default values for other properties. In the example shown, the injected store returns a default value for flights$.

In the past, this also worked with dependencies injected via the constructor. However, this procedure is not EcmaScript-compliant! EcmaScript first populates the properties with default values and only then executes the constructor. TypeScript now follows this specification. However, most Angular don\'t recognize this behavior because the Angular CLI activates the original behavior with a flag in the TypeScript configuration. With inject, on the other hand, we are per se standard-compliant and on the safe side even without this setting!

Bootstrapping a Standalone Component

The bootstrapping of the application is also easier thanks to standalone components: You only have to transfer the AppComponent to bootstrapApplication. Global providers can be set up using the second argument:

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(
      withInterceptors([authInterceptor]),
    ),
    provideRouter(APP_ROUTES, 
      withPreloading(PreloadAllModules),
    ),
    provideLogger({ debug: true }, 
          CustomLogFormatter);
  ]
}

Both the HttpClient and the router now come with helper functions for setting up the required global providers. The Angular team follows the provideXYZ naming pattern, which it also recommends for third-party libraries. The example shown also applies this pattern to a custom logger library. The next section shows how to create such a function for setting up your own library.

Own Provide Functions

Actually, provide functions for setting up libraries are nothing special. They accept the configuration parameters and return an array with providers:

export function provideLogger(config: LoggerConfig, 
        formatterClass: Type<LogFormatter>): EnvironmentProviders {
  return makeEnvironmentProviders([
    {
      provide: LoggerConfig,
      useValue: config,
    },
    {
      provide: LogFormatter,
      useClass: formatterClass,
    },
  ]);
}

However, the Angular team does not use the Provider[] type for the return value, but rather EnvironmentProviders. Using the type system, this procedure ensures that the providers supplied can only be registered together with bootstrapApplication and within router configurations. However, this excludes registration as a local provider within a component. This is important since most libraries are shared between multiple components. To create this type, Angular provides the makeEnvironmentProviders function.

More details on custom provide function can be found in my article here.

Functional Interceptors

One of the most powerful features of the HttpClient are interceptors. They provide central routines that can process all outgoing HTTP requests and incoming HTTP responses.

Originally, these were services that were to be provided via a multi-provider. However, when revising the HttpClient, which became necessary as part of the switch to standalone components, the Angular team created a lighter variant: functional interceptors.

These are simple functions that take the current HTTP request and a reference called next. This reference points either to another interceptor, if one exists, or to the logic that takes care of the desired HTTP request:

export const authInterceptor: HttpInterceptorFn = (req, next) => {

    if (req.url.startsWith('https://demo.angulararchitects.io/api/')) {
        // Setting a dummy token for demonstration
        const headers = req.headers.set('Authorization', 'Bearer Auth-1234567');
        req = req.clone({headers});
    }

    return next(req).pipe(
        tap(resp => console.log('response', resp))
    );
}

In order to make the application\'s functional interceptor known, it must be passed to provideHttpClient (see example above). The originally necessary packaging into a service and registration via a multi-provider is no longer necessary.

More details about Functional Interceptors and the Standalone API for the HttpClient can be found here.

More: Angular Architecture Workshop (online, interactive, advanced)

Become an expert for enterprise-scale and maintainable Angular applications with our Angular Architecture workshop!

All Details (English Workshop) | All Details (German Workshop)

Functional Guards and Resolvers

Similar to interceptors, guards and resolvers can now also be just functions. In cases where these constructs only delegate to services, they can even be reduced to a one-liner:

export const APP_ROUTES: Routes = [
    […]
    {
        path: 'flight-booking',
        canActivate: [() => inject(AuthService).isAuthenticated()],
        resolve: {
          config: () => inject(ConfigService).loaded$,
        },
        loadChildren: () =>
            import('./booking/flight-booking.routes')
               // .then(m => m.FLIGHT`BOOKING`ROUTES)
    },
    […]
]

In the example shown, a canActivate guard prevents unauthenticated users from activating a route. To do this, it delegates to the AuthService , which can be obtained via inject .

The resolver is similar: it delays the activation of the route until a configuration has been loaded. It also obtains the ConfigService required for this via inject.

This example also shows another new possibility that has also shortened lazy loading somewhat since Angular 15: The loadChildren property now uses the default expert. A projection onto the desired export indicated in the source code comment is no longer necessary.

More Details on Routing With Standalone Components can be found here.

Host Directives

The host directives introduced with Angular 15 allow reusable features to be included in components and directives. These are directives that are imported directly into other components and directives. There is now a new property hostDirectives in the directive and component decorator:

@Component({
  selector: 'tickets-flight-lookup',
  standalone: true,
  hostDirectives: [LifeCycle],
  […]
})
export class FlightLookupComponent implements OnInit {
  facade = inject(FlightLookupFacade);
  lifeCycle = inject(LifeCycle);

  flights$ = this.facade.flights$;

  ngOnInit(): void {

    this.flights$.pipe(takeUntil(this.lifeCycle.destroy$))
        .subscribe((v) => {
          console.log('online', v);
        });
  }
}

An interesting feature of host directives is that they share the lifecycle with the consuming component or directive, but also that they can be injected like traditional services. This allows, for example, the provision of observables that represent life cycle hooks. Such constructs make it easier, among other things, to close resources using takeUntil, as shown in the previous example. The implementation of the host directive used for this can be found in the next one:

@Directive({
  standalone: true,
})
export class LifeCycle implements […], OnDestroy {
  […]

  private destroySubject = new Subject<void>();
  readonly destroy$ = this.destroySubject.asObservable();

  […]

  ngOnDestroy(): void {
    this.destroySubject.next();
  }
}

Another Small Step: Self-Closing Tags

Another small new feature that helps to make our solutions more lightweight is self-closing tags. Beginning with Angular 15.1.0 we can now write

<flight-card [item]="f" [(selected)]="basket[f.id]" />

instead of

<flight-card [item]="f" [(selected)]="basket[f.id]">
</flight-card>

Obviously, this is a tiny new feature. However, it\'s another piece in the mosaic of more lightweight applications.

What\'s next? More on Architecture!

Please find more information on enterprise-scale Angular architectures in our free eBook (5th edition, 12 chapters):

  • According to which criteria can we subdivide a huge application into sub-domains?
  • How can we make sure, the solution is maintainable for years or even decades?
  • Which options from Micro Frontends are provided by Module Federation?

free

Feel free to download it here now!