Using Rich Model in Angular Development

Angular is a very conservative framework in terms of development standardization, which is why it has proven itself for large-scale commercial development of SPA applications. Indeed, if you use Angular, then you just need to find a specialist with the necessary qualifications, and he will not experience difficulties with the technical part of the project. But today I would like to break down the idea of ​​Angular development a little, and broaden your view of Angular application development.

The article is intended for confident Angular developers, and assumes that you have experience developing medium to large applications.

Let’s imagine that we want to develop a classic to-do list application. We have an anemic list item model:

interface Todo {
  id: string;
  name: string; // Название
  date: string; // Дата создания
}

And the service that stores the to-do list:

@Injectable({ providedIn: 'root' })
class TodoService {
  private readonly _todos$ = new BehaviorSubject<Todo[]>([]);

  get todos$ = this._todo$.asObservable();

  addTodo(todo: Todo) {
    this._todos$.next([...this._todos$.value, todo]);
  }
}

To display the to-do list, we will use a simple component:

@Component({
  selector: 'todo-list',
  template: `
    <button type="button" class="add-todo" (click)="addTodo()">Добавить элемент</button>
    <div *ngFor="let todo of todos$ | async; trackBy: trackById" class="todo">
      <span class="todo-name">{{todo.name}}</span>
      <span class="todo-date">{{todo.date | date}}</span>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
class TodoListComponent {
  private readonly _todoService = inject(TodoService);

  readonly todos$ = this._todoService.todos$;

  addTodo() {
    this._todoService.addTodo({
      id: generateGuid(), // Генерация GUID опущена для простоты
      name: 'Новое дело',
      date: new Date()
    } as Todo);
  }

  trackById(index: number, todo: Todo) {
    return todo.id;
  }
}

Please note that we did not store the to-do list directly in the component. This approach is often used when developing Angular applications. This approach allows you to separate data and business logic from the presentation. All application logic is stored in the service, and the component is the display. You can think of this as some variation of MVVM.

When creating a new case, we create a default object (see method addTodo() in the component), and we want to give the user the ability to edit it. Let’s add a new method to our service:

@Injectable({ providedIn: 'root' })
class TodoService {
  private readonly _todos$ = new BehaviorSubject<Todo[]>([]);

  get todos$ = this._todo$.asObservable();

  addTodo(todo: Todo) {
    this._todos$.next([...this._todos$.value, todo]);
  }

  // Добавили метод редактирования
  editTodo(editTodoId: string, name: string) {
    let todos = this._todos$.value;
    
    // Находим в списке дело, которое пользователь хочет изменить
    const editTodo = todos.find(todo => todo.id === editTodoId);

    // Если пользователь пытается отредактировать несуществующее дело
    // то ничего не делаем
    if (!editTodo) {
      return;
    }
    
    // Обновим список дел
    todos = todos.map(
      todo => todo.id !== editTodoId ?
        todo : { ...todo, name }
    )
    
    this._todos$.next(todos);
  }
}

Let’s also update the component:

@Component({
  selector: 'todo-list',
  template: `
    <button type="button" class="add-todo" (click)="addTodo()">Добавить элемент</button>
    <div *ngFor="let todo of todos$ | async; trackBy: trackById" class="todo">
      <span class="todo-name">{{todo.name}}</span>
      <span class="todo-date">{{todo.date | date}}</span>
      <button type="button" (click)="editTodo(todo.id)">Редактировать</button>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
class TodoListComponent {
  private readonly _todoService = inject(TodoService);

  readonly todos$ = this._todoService.todos$;

  addTodo() {
    this._todoService.addTodo({
      id: generateGuid(),
      name: 'Новое дело',
      date: new Date()
    } as Todo);
  }

  editTodo(editTodoId: string) {
    // Получаем новое значение от пользователя
    const name: string = prompt("Введите значение");

    // Обновляем список дел
    this._todoService.editTodo(editTodoId, name);
  }
  
  trackById(index: number, todo: Todo) {
    return todo.id;
  }
}

It turned out that this is not so easy to do. The method code in the service was not the simplest and most concise. This is due to some features of change detection in the bowels of Angular.

As you may have noticed, we use the mechanism OnPush as a strategy for detecting changes within a component. In this case, to continuously display a changing to-do list, we use a filter asyncwho subscribes to todos$ and updates the list every time a value is referenced inside todos$ changes. Therefore, whenever the array object changes, we need to generate a new reference to the array (which we successfully do using the method map), otherwise our application will not be able to recognize that changes have occurred in the array and redraw it.

This trivial example clearly demonstrates one of the many problems of anemic models when developing applications in Angular. But what if I told you that this example might look different:

class Todo extends BaseModel<Todo> {
  id: string;
  date: Date;

  constructor(public name: string) {
    this.id = generateGuid();
    this.date = new Date();
  }

  setName(name: string) {
    this.update({ name });
  }
}
@Injectable({ providedIn: 'root' })
class TodoService extends DataService<Todo> {
  // Сервис пустой
}
@Component({
  selector: 'todo-list',
  template: `
    <button type="button" class="add-todo" (click)="addTodo()">Добавить элемент</button>
    <div *ngFor="let todo of todos$ | async; trackBy: trackById" class="todo">
      <span class="todo-name">{{todo.name}}</span>
      <span class="todo-date">{{todo.date | date}}</span>
      <button type="button" (click)="editTodo(todo)">Редактировать</button>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
class TodoListComponent {
  private readonly _todoService = inject(TodoService);

  readonly todos$ = this._todoService.data$;

  addTodo() {
    // Метод add() был унаследован от DataService
    this._todoService.add(new Todo('Новое дело'))
  }

  editTodo(todo: Todo) {
    // Получаем новое значение от пользователя
    const name: string = prompt("Введите значение");

    // Обновляем список дел
    todo.setName(name);
  }
  
  trackById(index: number, todo: Todo) {
    return todo.id;
  }
}

Let’s summarize the changes:

  • Instead of an anemic model Todowhich was an object, we used a class that inherits from BaseModel.

  • Service TodoService now inherits from class DataServicewhich can process a list of models.

  • Instead of calling a class method to change the model, we call a method on the model itself inside the component. This way we don’t have to search for the model in the list and make point changes.

The code has become much simpler in terms of perception. In fact, this approach provides even more benefits:

We can expand the models

Let’s imagine that in our application there is not a to-do list, but also a separate list of reminders:

class Notification extends Todo {
   dueDate: Date; // Дата напоминания

    constructor(name: string, dueDate: Date) {
      super(name);

      this.dueDate = dueDate;
    }
}

If our application has many models of the same type, with a slight change in behavior, then we can significantly reduce the amount of code to describe them.

Thanks to inheritance, we do not need to duplicate the change code for each model of our application; it is enough to inherit from DataService

Thus, we will get the same functionality of adding and changing for models Notification in a separate service:

@Injectable({ providedIn: 'root' })
class NotificationsService extends DataService<Notification> {}

The more models, the less code we will have to write in the end. Wonderful!

We can determine the model type using instanceof

Let’s imagine that we come across an array of cases and notifications, and we don’t know which of them is which. In the case of anemic models (without using classes), the only way to find out is to check the fielddueDate models. If the field is present, then it is Notificationotherwise Todo.

const isNotification = Boolean(todo?.dueDate);

But what if dueDate was installed in null or undefined as a result of an error or intentionally? In this case, we lose the opportunity to somehow determine the type of data. Our project may have dozens of complex models for which species determination may be a challenge. For example, if to determine the type of data we have to check several fields for different conditions.

When using classes, we can define the appearance of our model by simply using intanceof:

const isNotification = todo instanceof Notification;

In this case, it no longer matters to us whether dueDate or not, the check will always work.

The logic of the model is inside the model

In OOP terms this is called encapsulation. In DDD terms this is called a rich model. When we use objects to store data, we can only receive and write data to the object:

const foo: Person = {
   name: 'Максим',
   age: 29,
   salary: 70_000,
   company: 'ООО Рога и Копыта',
   isActive: true,
};

console.log(foo.name) // Считали данные
foo.name="Дмитрий"; // Записали данные

Most Angular applications use anemic models, and the business logic for working with models is transferred to services.

Rich models can perform business logic and manage their data independently:

const user = new Person('Максим', 29, 70_000, 'ООО Рога и Копыта');

user.print();
user.deactivate();

We do not have to contact the service to manipulate the model; it is self-sufficient.

This fact also simplifies working with the code. It is convenient when the model and its methods are described in one file.

How it works?

So, we have found that using rich model (classes) to describe models can be beneficial when developing Angular applications. Let’s understand how to implement this in a real project.

Although I said that the model is self-sufficient, this is not entirely true. Calling a method setName() in a class object Todothe model must be able to tell the service that it has changed, and the service must respond by generating a new array reference so that Angular knows to redraw the component.

To implement such a mechanism, I decided to turn to the already existing practice of implementing forms in Angular. If we want to create our own form element, we must implement the interface ControlValueAccessor:

interface ControlValueAccessor {
  writeValue(obj: any): void
  registerOnChange(fn: any): void
  registerOnTouched(fn: any): void
  setDisabledState(isDisabled: boolean)?: void
}

We are interested in the method registerOnChangewhich takes a function as an argument. When creating a form element, Angular uses this method to get a function that should be called when the value of our form element changes. That is, every time we call the function fn from argument registerOnChangeAngular understands that there has been a change in the value of a form element.

In the same way, the service wants to know when changes occurred in the model. Therefore, let’s try to transfer this mechanism registerOnChange to implement the plan:

export abstract class BaseModel<T> {
  // Функция _onChange будет установлена сервисом при добавлении модели в список
  private _onChange: Function;

  // Модель может вызвать onChange() чтобы сообщить о своих изменениях
  protected onChange() {
    this._onChange?.();
  }

  // Всопомгательный метод для обновлении модели
  update(obj: Partial<T>) {
    Object.assign(this, obj);
    this._onChange?.();
  }

  // Данный метод вызовет сервис, чтобы установить _onChange
  registerOnChange(onChange: Function) {
    this._onChange = onChange;
  }
}

Now let’s describe the basic version of the class DataService:

abstract class DataService<T extends BaseModel<T>> {
  // Хранит список моделей (сущностей)
  protected readonly _data$ = new BehaviorSubject<T[]>([]);

  // Используется компонентом для отображения списка моделей
  readonly data$ = this._data$.asObservable();

  constructor() {
    this._onChange = this._onChange.bind(this);
  }

  // Добавление модели в список
  add(...models: T[]) {
    // Устанавливаем _onChange для модели
    models.forEach(model => {
      model.registerOnChange(this._onChange);
    });

    this._data$.next([...this._data$.value, ...models]);
  }

  // Удаление модели из списка
  delete(deleteModel: T) {
    if (!deleteModel) {
      return;
    }

    this._data$.next(this._data$.value.filter(model => model !== deleteModel));
  }

  clear() {
    this._data$.next([]);
    this._onChange();
  }

  protected _onChange() {
    // При изменении модели, генерируем новую ссылку на массив
    this._data$.next([...this._data$.value]);
  }
}

This way the desired result is achieved. Every time the model changes, the method is called _onChange()which causes the service to generate a new array reference and redraw the component.

In this example, a primitive version of the class was described DataService, which is intended to demonstrate the basic implementation of the principle. In real applications it is recommended to extend DataService and its descendants for support:

  • Redraw optimizations. If several models updated their state at once, then DataService should generate the redraw event once.

  • Multiple model updates. Sometimes it is useful to update the same field according to a certain criterion. For example, increase the salary of all female employees. Updating each model individually is not always effective.

  • Search by models. If you want to get a selection of models based on a certain criterion.

  • Dependency Injection support. Controversial, but sometimes useful thing. The model in our case cannot use external dependencies, make requests, or receive application configuration. By extending the class DataService,We can provide some model capabilities to be injected into the application injector.

Afterword

I am currently supporting several projects that implement this model management approach, this was due to the specific requirements of these projects. In no case would I want to create the impression of superiority of rich models over the classical approach. It is a tool that can be used to achieve specific goals when necessary. I believe that the proposed approach has a right to exist, but I would like to know the opinion of the community.

In addition, it seems to me that this approach is little known and not described sufficiently, which prompted me to write this post. I hope someone can find another way to implement projects on their favorite framework. Thank you for your attention.

PS Perhaps the terminology that was used in the article is not accurate, but I would like to leave this to the conscience of the author.

Similar Posts

Leave a Reply

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