Build Angular Apps Using Advanced DI Features

My name is Andrey, and I am developing a frontend in Angular for the company’s internal products. The framework has extensive capabilities, the same tasks can be solved in a huge number of ways. To make my job easier and more productive, I set out to find a universal and simple approach that would simplify design and reduce the amount of code while maintaining readability. After trying many different options and taking into account the mistakes made, I came to the architecture that I want to share in this article.

Puff Pie Application

As you know, an Angular application is a tree of components that inject dependencies from modular or element injectors… At the same time, his task as a client is to receive information from the server, which is converted to the desired form and displayed in the browser. And user actions on the page cause a change in information and visual presentation. Thus, the application is broken down into three abstract layers or layers that interact with each other:

  1. Data storage and operations with data (data layer).

  2. Converting information to the form required for display, processing user actions (control layer or controller).

  3. Data visualization and event delegation (presentation layer).

In the context of the framework, they will have the following characteristic features:

  • presentation layer elements – components;

  • control layer dependencies are in element injectors, and data layer dependencies are in modular;

  • communication between layers is carried out by means of the DI system;

  • elements of each level may have additional dependencies that are not directly related to the layer;

  • layers are linked in a strict order: services of the same layer cannot depend on each other, components of a presentation layer can inject only controllers, and controllers can only inject services of a data layer.

The last requirement may not be the most obvious. However, in my experience, code where services from the same tier communicate directly becomes too complex to understand. After a while, such code is easier to rewrite than to understand the relationships between the layers. If the requirement is fulfilled, the links remain as transparent as possible, it is much easier to read and maintain such code.

Generally speaking, data transferred between layers refers to arbitrary objects. However, in most cases they will be Observables, which are ideal for the described approach. Typically, the data layer provides an Observable with a portion of the application state. Then, in the control layer, using rxjs operators, the data is converted to the desired format, and in the component template, the subscription is carried out via an async pipe. Events on the page are associated with a handler in the controller. It can have complex logic for managing requests to the data layer and subscribes to Observables that return asynchronous commands. Subscription allows you to flexibly respond to the result of executing individual commands and handle errors, for example, by opening pop-up messages. I will refer to the elements of the control layer as controllers, although they differ from those in MVC pattern.

Data layer

Data layer services store the state of the application (business data, interface state) in a form that is convenient for working with it. Services for working with data are used as additional dependencies (for example: http client and state managers). For direct storage of data, it is convenient to use BehaviourSubject in simple cases, and libraries such as akita, Rxjs, or ngxs for more complex ones. However, in my opinion, the last two are redundant in this approach. Best suited for the proposed architecture akita… Its advantages are the absence of a boilerplate and the ability to reuse states by ordinary inheritance. At the same time, you can update the state directly in the rxjs query operators, which is much more convenient than creating actions.

@Injectable({providedIn: 'root'})
export class HeroState {
  private hero = new BehaviorSubject(null);

  constructor(private heroService: HeroService) {}

  load(id: string) {
    return this.heroService.load(id).pipe(tap(hero => this.hero.next(hero)));
  }

  save(hero: Hero) {
    return this.heroService.save(hero).pipe(tap(hero => this.hero.next(hero)));
  }

  get hero$(): Observable<Hero> {
    return this.hero.asObservable();
  }
}

Control layer

Since each service in the layer refers to a specific component with its subtree, it is logical to call the service a component controller. Due to the fact that the controller of the component is in the element injector, it is possible to use the OnDestroy hook in it and inject the same dependencies as in the component, for example ActivatedRoute. Of course, you do not need to create a separate service for the controller in cases where this is tantamount to removing the code from the component.

In addition to dependencies from the data layer, dependencies that control visualization (for example: opening dialogs, router) and helping with data transformation (for example: FormBuilder) can be injected into the controller.

@Injectable()
export class HeroController implements OnDestroy {
  private heroSubscription: Subscription;
  
  heroForm = this.fb.group({
    id: [],
    name: ['', Validators.required],
    power: ['', Validators.required]
  });

  constructor(private heroState: HeroState, private route: ActivatedRoute, private fb: FormBuilder) { }

  save() {
    this.heroState.save(this.heroForm.value).subscribe();
  }

  initialize() {
    this.route.paramMap.pipe(
      map(params => params.get('id')),
      switchMap(id => this.heroState.load(id)),
    ).subscribe();
    this.heroSubscription = this.heroState.selectHero().subscribe(hero => this.heroForm.reset(hero));
  }
  
  ngOnDestroy() {
    this.heroSubscription.unsubscribe();
  }
}

Presentation layer

The function of the presentation layer is to render and associate events with their handlers. Both happen in the component template. In this case, the class will contain only the code that injects control plane dependencies. Simple components (including those from external libraries) that do not use injection will be considered additional dependencies. They receive data through Input fields and delegate events through Output.

@Component({
  selector: 'hero',
  template: `
    <hero-form [form]="heroController.heroForm"></hero-form>
    <button (click)="heroController.save()">Save</button>
  `,
  providers: [HeroController]
})
export class HeroComponent {
  constructor(public heroController: HeroController) {
    this.heroController.initialize();
  }
}

Code reuse

Often in the process of developing an application, some of the markup, behavior and business logic begins to duplicate. Usually this problem is solved by using inheritance and writing reusable components. Layering in the manner described above allows for more flexible abstraction with less code. The main idea is to inject the dependencies of the layer that we are going to reuse, specifying not concrete, but abstract classes. Thus, two basic techniques can be distinguished: substitution of the data layer and substitution of the controller. In the first case, it is not known in advance what data the controller will work with. In the second, what is displayed and what will be the reaction to events.

In the demo application, I tried to fit the various methods of using them. Perhaps, they are a little redundant here, but they will be useful in real tasks.

The example demonstrates the implementation of a user interface that allows you to load a list of entities and display it in different tabs with the ability to edit and save each item.

First, let’s describe an abstract class for the data layer that will be used in the services of the control layer. Its concrete implementation will be indicated via useExisting provider.

export abstract class EntityState<T> {
    abstract get entities$(): Observable<T[]>; // список сущностей

    abstract get selectedId$(): Observable<string>; // id выбранного элемента

    abstract get selected$(): Observable<T>; // выбранный элемент

    abstract select(id: string); // выбрать элемент с указанным id

    abstract load(): Observable<T[]> // загрузить список

    abstract save(entity: T): Observable<T>; // сохранить сущность
}

Now let’s create a component for a card with a shape. Since the form here can be arbitrary, we will display it using the content projection. The bean controller injects EntityState and uses the method to persist the data.

@Injectable()
export class EntityCardController {
    isSelected$ = this.entityState.selectedId$.pipe(map(id => id !== null));

    constructor(private entityState: EntityState<any>, private snackBar: MatSnackBar) {
    }

    save(form: FormGroup) {
        this.entityState.save(form.value).subscribe({
            next: () => this.snackBar.open('Saved successfully', null, { duration: 2000 }),
            error: () => this.snackBar.open('Error occurred while saving', null, { duration: 2000 })
        })
    }
}

In the component itself, we use another way of dependency injection – through the directive @ContentChild.

@Component({
    selector: 'entity-card',
    template: `
        <mat-card>
            <ng-container *ngIf="entityCardController.isSelected$ | async; else notSelected">
                <mat-card-title>
                    <ng-content select=".header"></ng-content>
                </mat-card-title>
                <mat-card-content>
                    <ng-content></ng-content>
                </mat-card-content>
                <mat-card-actions>
                    <button mat-button (click)="entityCardController.save(entityFormController.entityForm)">SAVE</button>
                </mat-card-actions>
            </ng-container>
            <ng-template #notSelected>Select Item</ng-template>
        </mat-card>
    `,
    providers: [EntityCardController]
})
export class EntityCardComponent {
    @ContentChild(EntityFormController) entityFormController: EntityFormController<any>;

    constructor(public entityCardController: EntityCardController) {
        this.entityCardController.initialize();
    }
}

In order for this to be possible, it is necessary to specify the EntityFormController implementation in the providers of the component that is projected into the entity-card:

providers: [{ provide: EntityFormController, useClass: HeroFormController }]

The template for a component using this card would look like this:

<entity-card>
	<hero-form></hero-form>
</entity-card>

It remains to deal with the list: entities contain different fields, so the data transformation is different. Clicking on an item in the list invokes the same command from the data layer. Let’s describe the base class of the controller that contains the common code.

export interface Entity {
    value: string;
    label: string;
}

@Injectable()
export abstract class EntityListController<T> {
    constructor(protected entityState: EntityState<T>) {}

    select(value: string) {
        this.entityState.select(value);
    }

    selected$ = this.entityState.selectedId$;

    abstract get entityList$(): Observable<Entity[]>;
}

To refine the conversion of a specific data model to a displayable view, it is now enough to declare the inheritor and override the abstract property.

@Injectable()
export class FilmsListController extends EntityListController<Film> {
    entityList$ = this.entityState.entities$.pipe(
        map(films => films.map(f => ({ value: f.id, label: f.title })))
    )
}

The list component uses this service, however its implementation will be provided by an external component.

@Component({
    selector: 'entity-list',
    template: `
        <mat-selection-list [multiple]="false" 
                            (selectionChange)="entityListController.select($event.options[0].value)">
            <mat-list-option *ngFor="let item of entityListController.entityList$ | async"
                             [selected]="item.value === (entityListController.selected$ | async)"
                             

My name is Andrey, and I am developing a frontend in Angular for the company's internal products. The framework has extensive capabilities, the same tasks can be solved in a huge number of ways. To make my job easier and more productive, I set out to find a universal and simple approach that would simplify design and reduce the amount of code while maintaining readability. After trying many different options and taking into account the mistakes made, I came to the architecture that I want to share in this article.

Puff Pie Application

As you know, an Angular application is a tree of components that inject dependencies from modular or element injectors... At the same time, his task as a client is to receive information from the server, which is converted to the desired form and displayed in the browser. And user actions on the page cause a change in information and visual presentation. Thus, the application is broken down into three abstract layers or layers that interact with each other:

  1. Data storage and operations with data (data layer).

  2. Converting information to the form required for display, processing user actions (control layer or controller).

  3. Data visualization and event delegation (presentation layer).

In the context of the framework, they will have the following characteristic features:

  • presentation layer elements - components;

  • control layer dependencies are in element injectors, and data layer dependencies are in modular;

  • communication between layers is carried out by means of the DI system;

  • elements of each level may have additional dependencies that are not directly related to the layer;

  • layers are linked in a strict order: services of the same layer cannot depend on each other, components of a presentation layer can inject only controllers, and controllers can only inject services of a data layer.

The last requirement may not be the most obvious. However, in my experience, code where services from the same tier communicate directly becomes too complex to understand. After a while, such code is easier to rewrite than to understand the relationships between the layers. If the requirement is fulfilled, the links remain as transparent as possible, it is much easier to read and maintain such code.

Generally speaking, data transferred between layers refers to arbitrary objects. However, in most cases they will be Observables, which are ideal for the described approach. Typically, the data layer provides an Observable with a portion of the application state. Then, in the control layer, using rxjs operators, the data is converted to the desired format, and in the component template, the subscription is carried out via an async pipe. Events on the page are associated with a handler in the controller. It can have complex logic for managing requests to the data layer and subscribes to Observables that return asynchronous commands. Subscription allows you to flexibly respond to the result of executing individual commands and handle errors, for example, by opening pop-up messages. I will refer to the elements of the control layer as controllers, although they differ from those in MVC pattern.

Data layer

Data layer services store the state of the application (business data, interface state) in a form that is convenient for working with it. Services for working with data are used as additional dependencies (for example: http client and state managers). For direct storage of data, it is convenient to use BehaviourSubject in simple cases, and libraries such as akita, Rxjs, or ngxs for more complex ones. However, in my opinion, the last two are redundant in this approach. Best suited for the proposed architecture akita... Its advantages are the absence of a boilerplate and the ability to reuse states by ordinary inheritance. At the same time, you can update the state directly in the rxjs query operators, which is much more convenient than creating actions.

@Injectable({providedIn: 'root'})
export class HeroState {
  private hero = new BehaviorSubject(null);

  constructor(private heroService: HeroService) {}

  load(id: string) {
    return this.heroService.load(id).pipe(tap(hero => this.hero.next(hero)));
  }

  save(hero: Hero) {
    return this.heroService.save(hero).pipe(tap(hero => this.hero.next(hero)));
  }

  get hero$(): Observable<Hero> {
    return this.hero.asObservable();
  }
}

Control layer

Since each service in the layer refers to a specific component with its subtree, it is logical to call the service a component controller. Due to the fact that the controller of the component is in the element injector, it is possible to use the OnDestroy hook in it and inject the same dependencies as in the component, for example ActivatedRoute. Of course, you do not need to create a separate service for the controller in cases where this is tantamount to removing the code from the component.

In addition to dependencies from the data layer, dependencies that control visualization (for example: opening dialogs, router) and helping with data transformation (for example: FormBuilder) can be injected into the controller.

@Injectable()
export class HeroController implements OnDestroy {
  private heroSubscription: Subscription;
  
  heroForm = this.fb.group({
    id: [],
    name: ['', Validators.required],
    power: ['', Validators.required]
  });

  constructor(private heroState: HeroState, private route: ActivatedRoute, private fb: FormBuilder) { }

  save() {
    this.heroState.save(this.heroForm.value).subscribe();
  }

  initialize() {
    this.route.paramMap.pipe(
      map(params => params.get('id')),
      switchMap(id => this.heroState.load(id)),
    ).subscribe();
    this.heroSubscription = this.heroState.selectHero().subscribe(hero => this.heroForm.reset(hero));
  }
  
  ngOnDestroy() {
    this.heroSubscription.unsubscribe();
  }
}

Presentation layer

The function of the presentation layer is to render and associate events with their handlers. Both happen in the component template. In this case, the class will contain only the code that injects control plane dependencies. Simple components (including those from external libraries) that do not use injection will be considered additional dependencies. They receive data through Input fields and delegate events through Output.

@Component({
  selector: 'hero',
  template: `
    <hero-form [form]="heroController.heroForm"></hero-form>
    <button (click)="heroController.save()">Save</button>
  `,
  providers: [HeroController]
})
export class HeroComponent {
  constructor(public heroController: HeroController) {
    this.heroController.initialize();
  }
}

Code reuse

Often in the process of developing an application, some of the markup, behavior and business logic begins to duplicate. Usually this problem is solved by using inheritance and writing reusable components. Layering in the manner described above allows for more flexible abstraction with less code. The main idea is to inject the dependencies of the layer that we are going to reuse, specifying not concrete, but abstract classes. Thus, two basic techniques can be distinguished: substitution of the data layer and substitution of the controller. In the first case, it is not known in advance what data the controller will work with. In the second, what is displayed and what will be the reaction to events.

In the demo application, I tried to fit the various methods of using them. Perhaps, they are a little redundant here, but they will be useful in real tasks.

The example demonstrates the implementation of a user interface that allows you to load a list of entities and display it in different tabs with the ability to edit and save each item.

First, let's describe an abstract class for the data layer that will be used in the services of the control layer. Its concrete implementation will be indicated via useExisting provider.

export abstract class EntityState<T> {
    abstract get entities$(): Observable<T[]>; // список сущностей

    abstract get selectedId$(): Observable<string>; // id выбранного элемента

    abstract get selected$(): Observable<T>; // выбранный элемент

    abstract select(id: string); // выбрать элемент с указанным id

    abstract load(): Observable<T[]> // загрузить список

    abstract save(entity: T): Observable<T>; // сохранить сущность
}

Now let's create a component for a card with a shape. Since the form here can be arbitrary, we will display it using the content projection. The bean controller injects EntityState and uses the method to persist the data.

@Injectable()
export class EntityCardController {
    isSelected$ = this.entityState.selectedId$.pipe(map(id => id !== null));

    constructor(private entityState: EntityState<any>, private snackBar: MatSnackBar) {
    }

    save(form: FormGroup) {
        this.entityState.save(form.value).subscribe({
            next: () => this.snackBar.open('Saved successfully', null, { duration: 2000 }),
            error: () => this.snackBar.open('Error occurred while saving', null, { duration: 2000 })
        })
    }
}

In the component itself, we use another way of dependency injection - through the directive @ContentChild.

@Component({
    selector: 'entity-card',
    template: `
        <mat-card>
            <ng-container *ngIf="entityCardController.isSelected$ | async; else notSelected">
                <mat-card-title>
                    <ng-content select=".header"></ng-content>
                </mat-card-title>
                <mat-card-content>
                    <ng-content></ng-content>
                </mat-card-content>
                <mat-card-actions>
                    <button mat-button (click)="entityCardController.save(entityFormController.entityForm)">SAVE</button>
                </mat-card-actions>
            </ng-container>
            <ng-template #notSelected>Select Item</ng-template>
        </mat-card>
    `,
    providers: [EntityCardController]
})
export class EntityCardComponent {
    @ContentChild(EntityFormController) entityFormController: EntityFormController<any>;

    constructor(public entityCardController: EntityCardController) {
        this.entityCardController.initialize();
    }
}

In order for this to be possible, it is necessary to specify the EntityFormController implementation in the providers of the component that is projected into the entity-card:

providers: [{ provide: EntityFormController, useClass: HeroFormController }]

The template for a component using this card would look like this:

<entity-card>
	<hero-form></hero-form>
</entity-card>

It remains to deal with the list: entities contain different fields, so the data transformation is different. Clicking on an item in the list invokes the same command from the data layer. Let's describe the base class of the controller that contains the common code.

export interface Entity {
    value: string;
    label: string;
}

@Injectable()
export abstract class EntityListController<T> {
    constructor(protected entityState: EntityState<T>) {}

    select(value: string) {
        this.entityState.select(value);
    }

    selected$ = this.entityState.selectedId$;

    abstract get entityList$(): Observable<Entity[]>;
}

To refine the conversion of a specific data model to a displayable view, it is now enough to declare the inheritor and override the abstract property.

@Injectable()
export class FilmsListController extends EntityListController<Film> {
    entityList$ = this.entityState.entities$.pipe(
        map(films => films.map(f => ({ value: f.id, label: f.title })))
    )
}

The list component uses this service, however its implementation will be provided by an external component.

@Component({
    selector: 'entity-list',
    template: `
        <mat-selection-list [multiple]="false" 
                            (selectionChange)="entityListController.select($event.options[0].value)">
            <mat-list-option *ngFor="let item of entityListController.entityList$ | async"
                             [selected]="item.value === (entityListController.selected$ | async)"
                             

My name is Andrey, and I am developing a frontend in Angular for the company's internal products. The framework has extensive capabilities, the same tasks can be solved in a huge number of ways. To make my job easier and more productive, I set out to find a universal and simple approach that would simplify design and reduce the amount of code while maintaining readability. After trying many different options and taking into account the mistakes made, I came to the architecture that I want to share in this article.

Puff Pie Application

As you know, an Angular application is a tree of components that inject dependencies from modular or element injectors... At the same time, his task as a client is to receive information from the server, which is converted to the desired form and displayed in the browser. And user actions on the page cause a change in information and visual presentation. Thus, the application is broken down into three abstract layers or layers that interact with each other:

  1. Data storage and operations with data (data layer).

  2. Converting information to the form required for display, processing user actions (control layer or controller).

  3. Data visualization and event delegation (presentation layer).

In the context of the framework, they will have the following characteristic features:

  • presentation layer elements - components;

  • control layer dependencies are in element injectors, and data layer dependencies are in modular;

  • communication between layers is carried out by means of the DI system;

  • elements of each level may have additional dependencies that are not directly related to the layer;

  • layers are linked in a strict order: services of the same layer cannot depend on each other, components of a presentation layer can inject only controllers, and controllers can only inject services of a data layer.

The last requirement may not be the most obvious. However, in my experience, code where services from the same tier communicate directly becomes too complex to understand. After a while, such code is easier to rewrite than to understand the relationships between the layers. If the requirement is fulfilled, the links remain as transparent as possible, it is much easier to read and maintain such code.

Generally speaking, data transferred between layers refers to arbitrary objects. However, in most cases they will be Observables, which are ideal for the described approach. Typically, the data layer provides an Observable with a portion of the application state. Then, in the control layer, using rxjs operators, the data is converted to the desired format, and in the component template, the subscription is carried out via an async pipe. Events on the page are associated with a handler in the controller. It can have complex logic for managing requests to the data layer and subscribes to Observables that return asynchronous commands. Subscription allows you to flexibly respond to the result of executing individual commands and handle errors, for example, by opening pop-up messages. I will refer to the elements of the control layer as controllers, although they differ from those in MVC pattern.

Data layer

Data layer services store the state of the application (business data, interface state) in a form that is convenient for working with it. Services for working with data are used as additional dependencies (for example: http client and state managers). For direct storage of data, it is convenient to use BehaviourSubject in simple cases, and libraries such as akita, Rxjs, or ngxs for more complex ones. However, in my opinion, the last two are redundant in this approach. Best suited for the proposed architecture akita... Its advantages are the absence of a boilerplate and the ability to reuse states by ordinary inheritance. At the same time, you can update the state directly in the rxjs query operators, which is much more convenient than creating actions.

@Injectable({providedIn: 'root'})
export class HeroState {
  private hero = new BehaviorSubject(null);

  constructor(private heroService: HeroService) {}

  load(id: string) {
    return this.heroService.load(id).pipe(tap(hero => this.hero.next(hero)));
  }

  save(hero: Hero) {
    return this.heroService.save(hero).pipe(tap(hero => this.hero.next(hero)));
  }

  get hero$(): Observable<Hero> {
    return this.hero.asObservable();
  }
}

Control layer

Since each service in the layer refers to a specific component with its subtree, it is logical to call the service a component controller. Due to the fact that the controller of the component is in the element injector, it is possible to use the OnDestroy hook in it and inject the same dependencies as in the component, for example ActivatedRoute. Of course, you do not need to create a separate service for the controller in cases where this is tantamount to removing the code from the component.

In addition to dependencies from the data layer, dependencies that control visualization (for example: opening dialogs, router) and helping with data transformation (for example: FormBuilder) can be injected into the controller.

@Injectable()
export class HeroController implements OnDestroy {
  private heroSubscription: Subscription;
  
  heroForm = this.fb.group({
    id: [],
    name: ['', Validators.required],
    power: ['', Validators.required]
  });

  constructor(private heroState: HeroState, private route: ActivatedRoute, private fb: FormBuilder) { }

  save() {
    this.heroState.save(this.heroForm.value).subscribe();
  }

  initialize() {
    this.route.paramMap.pipe(
      map(params => params.get('id')),
      switchMap(id => this.heroState.load(id)),
    ).subscribe();
    this.heroSubscription = this.heroState.selectHero().subscribe(hero => this.heroForm.reset(hero));
  }
  
  ngOnDestroy() {
    this.heroSubscription.unsubscribe();
  }
}

Presentation layer

The function of the presentation layer is to render and associate events with their handlers. Both happen in the component template. In this case, the class will contain only the code that injects control plane dependencies. Simple components (including those from external libraries) that do not use injection will be considered additional dependencies. They receive data through Input fields and delegate events through Output.

@Component({
  selector: 'hero',
  template: `
    <hero-form [form]="heroController.heroForm"></hero-form>
    <button (click)="heroController.save()">Save</button>
  `,
  providers: [HeroController]
})
export class HeroComponent {
  constructor(public heroController: HeroController) {
    this.heroController.initialize();
  }
}

Code reuse

Often in the process of developing an application, some of the markup, behavior and business logic begins to duplicate. Usually this problem is solved by using inheritance and writing reusable components. Layering in the manner described above allows for more flexible abstraction with less code. The main idea is to inject the dependencies of the layer that we are going to reuse, specifying not concrete, but abstract classes. Thus, two basic techniques can be distinguished: substitution of the data layer and substitution of the controller. In the first case, it is not known in advance what data the controller will work with. In the second, what is displayed and what will be the reaction to events.

In the demo application, I tried to fit the various methods of using them. Perhaps, they are a little redundant here, but they will be useful in real tasks.

The example demonstrates the implementation of a user interface that allows you to load a list of entities and display it in different tabs with the ability to edit and save each item.

First, let's describe an abstract class for the data layer that will be used in the services of the control layer. Its concrete implementation will be indicated via useExisting provider.

export abstract class EntityState<T> {
    abstract get entities$(): Observable<T[]>; // список сущностей

    abstract get selectedId$(): Observable<string>; // id выбранного элемента

    abstract get selected$(): Observable<T>; // выбранный элемент

    abstract select(id: string); // выбрать элемент с указанным id

    abstract load(): Observable<T[]> // загрузить список

    abstract save(entity: T): Observable<T>; // сохранить сущность
}

Now let's create a component for a card with a shape. Since the form here can be arbitrary, we will display it using the content projection. The bean controller injects EntityState and uses the method to persist the data.

@Injectable()
export class EntityCardController {
    isSelected$ = this.entityState.selectedId$.pipe(map(id => id !== null));

    constructor(private entityState: EntityState<any>, private snackBar: MatSnackBar) {
    }

    save(form: FormGroup) {
        this.entityState.save(form.value).subscribe({
            next: () => this.snackBar.open('Saved successfully', null, { duration: 2000 }),
            error: () => this.snackBar.open('Error occurred while saving', null, { duration: 2000 })
        })
    }
}

In the component itself, we use another way of dependency injection - through the directive @ContentChild.

@Component({
    selector: 'entity-card',
    template: `
        <mat-card>
            <ng-container *ngIf="entityCardController.isSelected$ | async; else notSelected">
                <mat-card-title>
                    <ng-content select=".header"></ng-content>
                </mat-card-title>
                <mat-card-content>
                    <ng-content></ng-content>
                </mat-card-content>
                <mat-card-actions>
                    <button mat-button (click)="entityCardController.save(entityFormController.entityForm)">SAVE</button>
                </mat-card-actions>
            </ng-container>
            <ng-template #notSelected>Select Item</ng-template>
        </mat-card>
    `,
    providers: [EntityCardController]
})
export class EntityCardComponent {
    @ContentChild(EntityFormController) entityFormController: EntityFormController<any>;

    constructor(public entityCardController: EntityCardController) {
        this.entityCardController.initialize();
    }
}

In order for this to be possible, it is necessary to specify the EntityFormController implementation in the providers of the component that is projected into the entity-card:

providers: [{ provide: EntityFormController, useClass: HeroFormController }]

The template for a component using this card would look like this:

<entity-card>
	<hero-form></hero-form>
</entity-card>

It remains to deal with the list: entities contain different fields, so the data transformation is different. Clicking on an item in the list invokes the same command from the data layer. Let's describe the base class of the controller that contains the common code.

export interface Entity {
    value: string;
    label: string;
}

@Injectable()
export abstract class EntityListController<T> {
    constructor(protected entityState: EntityState<T>) {}

    select(value: string) {
        this.entityState.select(value);
    }

    selected$ = this.entityState.selectedId$;

    abstract get entityList$(): Observable<Entity[]>;
}

To refine the conversion of a specific data model to a displayable view, it is now enough to declare the inheritor and override the abstract property.

@Injectable()
export class FilmsListController extends EntityListController<Film> {
    entityList$ = this.entityState.entities$.pipe(
        map(films => films.map(f => ({ value: f.id, label: f.title })))
    )
}

The list component uses this service, however its implementation will be provided by an external component.

@Component({
    selector: 'entity-list',
    template: `
        <mat-selection-list [multiple]="false" 
                            (selectionChange)="entityListController.select($event.options[0].value)">
            <mat-list-option *ngFor="let item of entityListController.entityList$ | async"
                             [selected]="item.value === (entityListController.selected$ | async)"
                             

My name is Andrey, and I am developing a frontend in Angular for the company's internal products. The framework has extensive capabilities, the same tasks can be solved in a huge number of ways. To make my job easier and more productive, I set out to find a universal and simple approach that would simplify design and reduce the amount of code while maintaining readability. After trying many different options and taking into account the mistakes made, I came to the architecture that I want to share in this article.

Puff Pie Application

As you know, an Angular application is a tree of components that inject dependencies from modular or element injectors... At the same time, his task as a client is to receive information from the server, which is converted to the desired form and displayed in the browser. And user actions on the page cause a change in information and visual presentation. Thus, the application is broken down into three abstract layers or layers that interact with each other:

  1. Data storage and operations with data (data layer).

  2. Converting information to the form required for display, processing user actions (control layer or controller).

  3. Data visualization and event delegation (presentation layer).

In the context of the framework, they will have the following characteristic features:

  • presentation layer elements - components;

  • control layer dependencies are in element injectors, and data layer dependencies are in modular;

  • communication between layers is carried out by means of the DI system;

  • elements of each level may have additional dependencies that are not directly related to the layer;

  • layers are linked in a strict order: services of the same layer cannot depend on each other, components of a presentation layer can inject only controllers, and controllers can only inject services of a data layer.

The last requirement may not be the most obvious. However, in my experience, code where services from the same tier communicate directly becomes too complex to understand. After a while, such code is easier to rewrite than to understand the relationships between the layers. If the requirement is fulfilled, the links remain as transparent as possible, it is much easier to read and maintain such code.

Generally speaking, data transferred between layers refers to arbitrary objects. However, in most cases they will be Observables, which are ideal for the described approach. Typically, the data layer provides an Observable with a portion of the application state. Then, in the control layer, using rxjs operators, the data is converted to the desired format, and in the component template, the subscription is carried out via an async pipe. Events on the page are associated with a handler in the controller. It can have complex logic for managing requests to the data layer and subscribes to Observables that return asynchronous commands. Subscription allows you to flexibly respond to the result of executing individual commands and handle errors, for example, by opening pop-up messages. I will refer to the elements of the control layer as controllers, although they differ from those in MVC pattern.

Data layer

Data layer services store the state of the application (business data, interface state) in a form that is convenient for working with it. Services for working with data are used as additional dependencies (for example: http client and state managers). For direct storage of data, it is convenient to use BehaviourSubject in simple cases, and libraries such as akita, Rxjs, or ngxs for more complex ones. However, in my opinion, the last two are redundant in this approach. Best suited for the proposed architecture akita... Its advantages are the absence of a boilerplate and the ability to reuse states by ordinary inheritance. At the same time, you can update the state directly in the rxjs query operators, which is much more convenient than creating actions.

@Injectable({providedIn: 'root'})
export class HeroState {
  private hero = new BehaviorSubject(null);

  constructor(private heroService: HeroService) {}

  load(id: string) {
    return this.heroService.load(id).pipe(tap(hero => this.hero.next(hero)));
  }

  save(hero: Hero) {
    return this.heroService.save(hero).pipe(tap(hero => this.hero.next(hero)));
  }

  get hero$(): Observable<Hero> {
    return this.hero.asObservable();
  }
}

Control layer

Since each service in the layer refers to a specific component with its subtree, it is logical to call the service a component controller. Due to the fact that the controller of the component is in the element injector, it is possible to use the OnDestroy hook in it and inject the same dependencies as in the component, for example ActivatedRoute. Of course, you do not need to create a separate service for the controller in cases where this is tantamount to removing the code from the component.

In addition to dependencies from the data layer, dependencies that control visualization (for example: opening dialogs, router) and helping with data transformation (for example: FormBuilder) can be injected into the controller.

@Injectable()
export class HeroController implements OnDestroy {
  private heroSubscription: Subscription;
  
  heroForm = this.fb.group({
    id: [],
    name: ['', Validators.required],
    power: ['', Validators.required]
  });

  constructor(private heroState: HeroState, private route: ActivatedRoute, private fb: FormBuilder) { }

  save() {
    this.heroState.save(this.heroForm.value).subscribe();
  }

  initialize() {
    this.route.paramMap.pipe(
      map(params => params.get('id')),
      switchMap(id => this.heroState.load(id)),
    ).subscribe();
    this.heroSubscription = this.heroState.selectHero().subscribe(hero => this.heroForm.reset(hero));
  }
  
  ngOnDestroy() {
    this.heroSubscription.unsubscribe();
  }
}

Presentation layer

The function of the presentation layer is to render and associate events with their handlers. Both happen in the component template. In this case, the class will contain only the code that injects control plane dependencies. Simple components (including those from external libraries) that do not use injection will be considered additional dependencies. They receive data through Input fields and delegate events through Output.

@Component({
  selector: 'hero',
  template: `
    <hero-form [form]="heroController.heroForm"></hero-form>
    <button (click)="heroController.save()">Save</button>
  `,
  providers: [HeroController]
})
export class HeroComponent {
  constructor(public heroController: HeroController) {
    this.heroController.initialize();
  }
}

Code reuse

Often in the process of developing an application, some of the markup, behavior and business logic begins to duplicate. Usually this problem is solved by using inheritance and writing reusable components. Layering in the manner described above allows for more flexible abstraction with less code. The main idea is to inject the dependencies of the layer that we are going to reuse, specifying not concrete, but abstract classes. Thus, two basic techniques can be distinguished: substitution of the data layer and substitution of the controller. In the first case, it is not known in advance what data the controller will work with. In the second, what is displayed and what will be the reaction to events.

In the demo application, I tried to fit the various methods of using them. Perhaps, they are a little redundant here, but they will be useful in real tasks.

The example demonstrates the implementation of a user interface that allows you to load a list of entities and display it in different tabs with the ability to edit and save each item.

First, let's describe an abstract class for the data layer that will be used in the services of the control layer. Its concrete implementation will be indicated via useExisting provider.

export abstract class EntityState<T> {
    abstract get entities$(): Observable<T[]>; // список сущностей

    abstract get selectedId$(): Observable<string>; // id выбранного элемента

    abstract get selected$(): Observable<T>; // выбранный элемент

    abstract select(id: string); // выбрать элемент с указанным id

    abstract load(): Observable<T[]> // загрузить список

    abstract save(entity: T): Observable<T>; // сохранить сущность
}

Now let's create a component for a card with a shape. Since the form here can be arbitrary, we will display it using the content projection. The bean controller injects EntityState and uses the method to persist the data.

@Injectable()
export class EntityCardController {
    isSelected$ = this.entityState.selectedId$.pipe(map(id => id !== null));

    constructor(private entityState: EntityState<any>, private snackBar: MatSnackBar) {
    }

    save(form: FormGroup) {
        this.entityState.save(form.value).subscribe({
            next: () => this.snackBar.open('Saved successfully', null, { duration: 2000 }),
            error: () => this.snackBar.open('Error occurred while saving', null, { duration: 2000 })
        })
    }
}

In the component itself, we use another way of dependency injection - through the directive @ContentChild.

@Component({
    selector: 'entity-card',
    template: `
        <mat-card>
            <ng-container *ngIf="entityCardController.isSelected$ | async; else notSelected">
                <mat-card-title>
                    <ng-content select=".header"></ng-content>
                </mat-card-title>
                <mat-card-content>
                    <ng-content></ng-content>
                </mat-card-content>
                <mat-card-actions>
                    <button mat-button (click)="entityCardController.save(entityFormController.entityForm)">SAVE</button>
                </mat-card-actions>
            </ng-container>
            <ng-template #notSelected>Select Item</ng-template>
        </mat-card>
    `,
    providers: [EntityCardController]
})
export class EntityCardComponent {
    @ContentChild(EntityFormController) entityFormController: EntityFormController<any>;

    constructor(public entityCardController: EntityCardController) {
        this.entityCardController.initialize();
    }
}

In order for this to be possible, it is necessary to specify the EntityFormController implementation in the providers of the component that is projected into the entity-card:

providers: [{ provide: EntityFormController, useClass: HeroFormController }]

The template for a component using this card would look like this:

<entity-card>
	<hero-form></hero-form>
</entity-card>

It remains to deal with the list: entities contain different fields, so the data transformation is different. Clicking on an item in the list invokes the same command from the data layer. Let's describe the base class of the controller that contains the common code.

export interface Entity {
    value: string;
    label: string;
}

@Injectable()
export abstract class EntityListController<T> {
    constructor(protected entityState: EntityState<T>) {}

    select(value: string) {
        this.entityState.select(value);
    }

    selected$ = this.entityState.selectedId$;

    abstract get entityList$(): Observable<Entity[]>;
}

To refine the conversion of a specific data model to a displayable view, it is now enough to declare the inheritor and override the abstract property.

@Injectable()
export class FilmsListController extends EntityListController<Film> {
    entityList$ = this.entityState.entities$.pipe(
        map(films => films.map(f => ({ value: f.id, label: f.title })))
    )
}

The list component uses this service, however its implementation will be provided by an external component.

@Component({
    selector: 'entity-list',
    template: `
        <mat-selection-list [multiple]="false" 
                            (selectionChange)="entityListController.select($event.options[0].value)">
            <mat-list-option *ngFor="let item of entityListController.entityList$ | async"
                             [selected]="item.value === (entityListController.selected$ | async)"
                             [value]="item.value">
                {{ item.label }}
            </mat-list-option>
        </mat-selection-list>
    `
})
export class EntityListComponent {
    constructor(public entityListController: EntityListController<any>) {}
}

The component, which is an abstraction of the entire tab, includes a list of entities and projects content with a form.

@Component({
    selector: 'entity-page',
    template: `
        <mat-sidenav-container>
            <mat-sidenav opened mode="side">
                <entity-list></entity-list>
            </mat-sidenav>
            <ng-content></ng-content>
        </mat-sidenav-container>
    `,
})
export class EntityPageComponent {}

Using entity-page bean:

@Component({
    selector: 'film-page',
    template: `
        <entity-page>
            <entity-card>
                <span class="header">Film</span>
                <film-form></film-form>
            </entity-card>
        </entity-page>
    `,
    providers: [
        { provide: EntityState, useExisting: FilmsState },
        { provide: EntityListController, useClass: FilmsListController }
    ]
})
export class FilmPageComponent {}

The entity-card bean is passed through the content projection for possibilities of using ContentChild...

Afterword

The described approach allowed me to significantly simplify the design process and speed up development without compromising the quality and readability of the code. It scales well to real-world tasks. In the examples, only basic reuse techniques have been demonstrated. Their combination with features such as multi-providers and access modifiers (Optional, Self, SkipSelf, Host) allows you to flexibly highlight abstractions in complex cases, using less code than the usual reuse of components.

="item.value">
{{ item.label }}
</mat-list-option>
</mat-selection-list>
`
})
export class EntityListComponent {
constructor(public entityListController: EntityListController<any>) {}
}

The component, which is an abstraction of the entire tab, includes a list of entities and projects content with a form.

@Component({
    selector: 'entity-page',
    template: `
        <mat-sidenav-container>
            <mat-sidenav opened mode="side">
                <entity-list></entity-list>
            </mat-sidenav>
            <ng-content></ng-content>
        </mat-sidenav-container>
    `,
})
export class EntityPageComponent {}

Using entity-page bean:

@Component({
    selector: 'film-page',
    template: `
        <entity-page>
            <entity-card>
                <span class="header">Film</span>
                <film-form></film-form>
            </entity-card>
        </entity-page>
    `,
    providers: [
        { provide: EntityState, useExisting: FilmsState },
        { provide: EntityListController, useClass: FilmsListController }
    ]
})
export class FilmPageComponent {}

The entity-card bean is passed through the content projection for possibilities of using ContentChild...

Afterword

The described approach allowed me to significantly simplify the design process and speed up development without compromising the quality and readability of the code. It scales well to real-world tasks. In the examples, only basic reuse techniques have been demonstrated. Their combination with features such as multi-providers and access modifiers (Optional, Self, SkipSelf, Host) allows you to flexibly highlight abstractions in complex cases, using less code than the usual reuse of components.

="item.value">
{{ item.label }}
</mat-list-option>
</mat-selection-list>
`
})
export class EntityListComponent {
constructor(public entityListController: EntityListController<any>) {}
}

The component, which is an abstraction of the entire tab, includes a list of entities and projects content with a form.

@Component({
    selector: 'entity-page',
    template: `
        <mat-sidenav-container>
            <mat-sidenav opened mode="side">
                <entity-list></entity-list>
            </mat-sidenav>
            <ng-content></ng-content>
        </mat-sidenav-container>
    `,
})
export class EntityPageComponent {}

Using entity-page bean:

@Component({
    selector: 'film-page',
    template: `
        <entity-page>
            <entity-card>
                <span class="header">Film</span>
                <film-form></film-form>
            </entity-card>
        </entity-page>
    `,
    providers: [
        { provide: EntityState, useExisting: FilmsState },
        { provide: EntityListController, useClass: FilmsListController }
    ]
})
export class FilmPageComponent {}

The entity-card bean is passed through the content projection for possibilities of using ContentChild...

Afterword

The described approach allowed me to significantly simplify the design process and speed up development without compromising the quality and readability of the code. It scales well to real-world tasks. In the examples, only basic reuse techniques have been demonstrated. Their combination with features such as multi-providers and access modifiers (Optional, Self, SkipSelf, Host) allows you to flexibly highlight abstractions in complex cases, using less code than the usual reuse of components.

="item.value">
{{ item.label }}
</mat-list-option>
</mat-selection-list>
`
})
export class EntityListComponent {
constructor(public entityListController: EntityListController<any>) {}
}

The component, which is an abstraction of the entire tab, includes a list of entities and projects content with a form.

@Component({
    selector: 'entity-page',
    template: `
        <mat-sidenav-container>
            <mat-sidenav opened mode="side">
                <entity-list></entity-list>
            </mat-sidenav>
            <ng-content></ng-content>
        </mat-sidenav-container>
    `,
})
export class EntityPageComponent {}

Using entity-page bean:

@Component({
    selector: 'film-page',
    template: `
        <entity-page>
            <entity-card>
                <span class="header">Film</span>
                <film-form></film-form>
            </entity-card>
        </entity-page>
    `,
    providers: [
        { provide: EntityState, useExisting: FilmsState },
        { provide: EntityListController, useClass: FilmsListController }
    ]
})
export class FilmPageComponent {}

The entity-card bean is passed through the content projection for possibilities of using ContentChild

Afterword

The described approach allowed me to significantly simplify the design process and speed up development without compromising the quality and readability of the code. It scales well to real-world tasks. In the examples, only basic reuse techniques have been demonstrated. Their combination with features such as multi-providers and access modifiers (Optional, Self, SkipSelf, Host) allows you to flexibly highlight abstractions in complex cases, using less code than the usual reuse of components.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *