Angular’ useful techniques: lazy loaded commands
As the title may suggest — one day, I was looking for a solution that would allow me to perform contextual business logic on demand. And “on demand” is very important because every new piece of code increases the loading time of an application as it grows. If a user is not authorized to perform a specific action, there is no reason for him to download unused source code. I also wanted to avoid code duplication and make adding additional contextual operations in the future easy. As I was exploring, I stumbled upon commands loading lazily.
When it comes to the command pattern itself, no one has described it better than Omar Barguti in his article Design Patterns: The Command Pattern. So, if you’re not familiar with this pattern, I highly recommend reading his article. To explore sample code implementing the command pattern in various languages, visit the Refactoring guru website.
In summary, what Omar wrote about the command pattern is:
- Command is a single business logic responsible for performing a specific task in the system.
- Commands usually implement a common interface so the system can execute them without knowing their logic.
- The command pattern promotes the Single Responsibility Principle by breaking logic into smaller commands for a specific task.
- The command pattern also promotes the Open/Closed Principle, allowing for project extension by adding new commands to the system without modifying existing code.
- The command pattern avoids code duplication.
The command pattern alone would be suitable for my needs if loading commands on demand weren’t that important. However, I still wanted to avoid loading unnecessary code into the application. So, I decided to add lazy loading to the commands. Let’s see how we can achieve this:
Let’s start with our interface:
import {Observable} from 'rxjs';
export interface UserAction {
execute(user: string): Observable<void>
}
Within the context of a user, our system will utilize this simple interface for various operations.
Let’s implement a sample command:
import {filter, Observable, of, switchMap} from 'rxjs';
import {UserAction} from '@app/features/user-actions';
import {DeactivateUserService} from './deactivate-user.service';
export class DeactivateUserCommand implements UserAction {
constructor(private readonly commandService: DeactivateUserService) {
}
execute(user: string): Observable<void> {
return of(void 0).pipe(
switchMap(() => this.commandService.confirm()),
filter(confirmed => confirmed),
switchMap(() => this.commandService.deactivate(user)),
)
}
}
This command deactivates the user in the system. However, before doing so, we want to confirm the operation. If confirmed, we will call the appropriate service to deactivate the user.
Let’s create a module that encapsulates services needed by our command. This module will also be responsible for creating an instance of the command.
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {UserAction} from '@app/features/user-actions';
import {UserModule} from '@app/shared/rest/user';
import {DeactivateUserCommand} from './deactivate-user.command';
import {DeactivateUserService} from './deactivate-user.service';
@NgModule({
declarations: [],
providers: [DeactivateUserService],
imports: [CommonModule, UserModule]
})
export class DeactivateUserModule {
constructor(private commandService: DeactivateUserService) {
}
createCommandInstance(): UserAction {
return new DeactivateUserCommand(this.commandService)
}
}
For understanding the code, the implementation of the command service is also listening:
import {Injectable} from '@angular/core';
import {Observable} from 'rxjs';
import {ConfirmationService} from '@app/shared/confirmation';
import {UserService} from '@app/shared/rest/user';
@Injectable()
export class DeactivateUserService {
constructor(
private readonly userRestService: UserService,
private readonly confirmationService: ConfirmationService) {
}
confirm(): Observable<boolean> {
return this.confirmationService.confirm();
}
deactivate(user: string): Observable<void> {
return this.userRestService.deactivate(user);
}
}
Finally, write a loader that will upload a module and keep it in a cache so that once loaded, it will be available to many clients in the system:
import {from, map, Observable, of, tap} from 'rxjs';
import {createNgModule, Injector, NgModuleRef} from '@angular/core';
import {UserAction} from '@app/features/user-actions';
import {DeactivateUserModule} from './deactivate-user.module';
export class DeactivateUserLoader {
private static moduleCache: DeactivateUserModule;
public static createInstance(injector: Injector): Observable<UserAction> {
if (!!DeactivateUserLoader.moduleCache) {
return of(DeactivateUserLoader.moduleCache.createCommandInstance());
}
return from(
import('./deactivate-user.module').then(m => createNgModule<DeactivateUserModule>(m.DeactivateUserModule, injector)))
.pipe(
tap((m: NgModuleRef<DeactivateUserModule>) => DeactivateUserLoader.moduleCache = m.instance),
map(() => DeactivateUserLoader.moduleCache.createCommandInstance())
);
}
}
The Injector
is a significant element here. It is necessary if our command module wants to access services other modules provide — all eagerly loaded into our application and others at the root
level.
Finally, we need an Invoker
to execute the command.
import {Injectable, Injector} from '@angular/core';
import {map, Observable, switchMap, take} from 'rxjs';
import {DeleteUserLoader} from './delete-user';
import {DeactivateUserLoader} from './deactivate-user';
import {UserAction} from './user-action';
export type UserActionType = 'Delete' | 'Deactivate';
@Injectable({providedIn: 'root'})
export class UserActionInvoker {
constructor(private readonly injector: Injector) {
}
execute(userAction: UserActionType, user: string): Observable<void> {
let command: Observable<UserAction>;
switch (userAction) {
case 'Delete':
command = DeleteUserLoader.createInstance(this.injector)
break;
case 'Deactivate':
command = DeactivateUserLoader.createInstance(this.injector)
break;
}
return command.pipe(
switchMap((command: UserAction) => command.execute(user)),
map(() => void 0)
);
}
}
Below is an example of a client who will execute the command:
import {Component} from '@angular/core';
import {UserActionInvoker, UserActionType} from '@app/features/user-actions';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styles: [`
<ng-container *ngFor="let user of users">
<h3>{{user}}</h3>
<ng-container *ngFor="let userAction of actions;">
<button (click)="performAction(userAction, user)">{{userAction}} - {{user}}</button>
</ng-container>
</ng-container>
`]
})
export class AppComponent {
protected actions: UserActionType[] = ['Delete', 'Deactivate'];
protected users = ['User One', 'User Two'];
constructor(private invoker: UserActionInvoker) {
}
protected performAction(userAction: UserActionType, user: string) {
this
.invoker
.execute(userAction, user)
.subscribe({
error: err => console.error(`Error occurred while performing action: '${err}'`),
complete: () => console.log(`Action done!`)
}
);
}
}
And so … thanks to lazy loaded commands, without duplicating the code, we can call contextual business logic on demand.
Below, you can find that the lazy chunks separate our commands from the main bundle.
They are loaded while the user executes them for the first time.
Live example: