Hello everyone, my name is Sergey and I am a web developer. God forgive me Dmitry Karlovsky for the borrowed introduction, but it was his publications that inspired me to write this article.
Today I would like to talk about working with data in Angular applications in general and domain models in particular.
Suppose that we have a certain list of users that we receive from the server in the form
[ { "id": 1, "first_name": "James", "last_name": "Hetfield", "position": "Web developer" }, { "id": 2, "first_name": "Elvis", "last_name": "", "position": "Project manager" }, { "id": 3, "first_name": "Steve", "last_name": "Vai", "position": "QA engineer" } ]
and you need to display it as in the picture
It looks easy - let's try. Of course, to get this list we will have a UserService
service of the following form. Please note that the link to the user's avatar does not come immediately in the response, but is formed on the basis of the user's id
.
// UserService import {Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {Observable} from 'rxjs'; import {UserServerResponse} from './user-server-response.interface'; @Injectable() export class UserService { constructor(private http: HttpClient) { } getUsers(): Observable<UserServerResponse[]> { return this.http.get<UserServerResponse[]>('/users'); } getUserAvatar(userId: number): string { return `/users/${userId}/avatar`; } }
UserListComponent
will be responsible for displaying the list of users
// UserListComponent import {Component} from '@angular/core'; import {UserService} from '../services/user.service'; @Component({ selector: 'app-user-list', template: ` <div *ngFor="let user of users | async"> <img [src]="userService.getUserAvatar(user.id)"> <p><b>{{user.first_name}} {{user.last_name}}</b>, {{user.position}}</p> </div> ` }) export class UserListComponent { users = this.userService.getUsers(); constructor(public userService: UserService) { } }
And here we already have a certain problem . Note the server response. The last_name
field can be empty and if we leave the component in this form, we will get unwanted spaces before the comma. What are the solutions?
You can slightly correct the display pattern
<p> <b>{{[user.first_name, user.last_name].filter(el => !!el).join(' ')}}</b>, {{user.position}} </p>
But this way we overload the template with logic, and it becomes poorly readable even for such a simple task. But the application is still growing and growing ...
Extract the code from the template to the component class by adding a type method
getUserFullName(user: UserServerResponse): string { return [user.first_name, user.last_name].filter(el => !!el).join(' '); }
Already better, but most likely the full username will be displayed in more than one place in the application, and we will have to duplicate this code. You can take this method from component to service. Thus, we will get rid of possible duplication of the code, but I don’t really like this option either. But I don’t like it because it turns out that some more general entity ( UserService
) needs to know about the structure of the smaller User
entity being passed to it. Not her level of responsibility, I think.
In my opinion, the problem primarily arises due to the fact that we treat the server response only as a data set. Although in fact it is a list of entities from the subject area of our application - a list of users. And if we are talking about working with entities, then you should use the most appropriate tool for this - methods of object-oriented programming.
Let's start by creating the User class.
// User export class User { readonly id; readonly firstName; readonly lastName; readonly position; constructor(userData: UserServerResponse) { this.id = userData.id; this.firstName = userData.first_name; this.lastName = userData.last_name; this.position = userData.position; } fullName(): string { return [this.firstName, this.lastName].filter(el => !!el).join(' '); } avatar(): string { return `/users/${this.id}/avatar`; } }
The class constructor is a server response deserializer. The logic of determining the full user name naturally becomes a method of an object of class User
, as well as the logic of getting an avatar. Now we will remake the UserService
so that it returns us objects of the User
class as the result of processing the server response
// UserService import {Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {map} from 'rxjs/operators'; import {UserServerResponse} from './user-server-response.interface'; import {User} from './user.model'; @Injectable() export class UserService { constructor(private http: HttpClient) { } getUsers(): Observable<User[]> { return this.http.get<UserServerResponse[]>('/users') .pipe(map(listOfUsers => listOfUsers.map(singleUser => new User(singleUser)))); } }
As a result, the code of our component becomes significantly cleaner and more readable. Everything that can be called business logic is encapsulated in models and is completely reusable.
import {Component} from '@angular/core'; import {UserService} from '../services/user.service'; @Component({ selector: 'app-user-list', template: ` <div *ngFor="let user of users | async"> <img [src]="user.avatar()"> <p><b>{{user.fullName()}}</b>, {{user.position}}</p> </div> ` }) export class UserListComponent { users = this.userService.getUsers(); constructor(private userService: UserService) { } }
Let's now expand the capabilities of our model. In theory (in this context, I like the analogy with the ActiveRecord
pattern), user model objects should be responsible not only for obtaining data about themselves, but also for changing them. For example, we may have the opportunity to change the user's avatar. How would the user model expanded with this functionality look like?
// User export class User { // ... constructor(userData: UserServerResponse, private http: HttpClient, private storage: StorageService, private auth: AuthService) { // ... } // ... updateAvatar(file: Blob) { const data = new FormData(); data.append('avatar', file); return this.http.put(`/users/${this.id}/avatar`, data); } }
It looks good, but the User
model now uses the HttpClient
service and, generally speaking, it can easily connect and use various other services - in this case, the StorageService
and AuthService
(they are not used, but added just for example). It turns out that if we want to use the User
model in some other service or component, we will have to connect all the services associated with it to create objects of this model. It looks very inconvenient ... You can use the Injector
service (of course, you will also have to implement it, but it is guaranteed to be only one) or even create an external entity of the injector that you don’t need to implement, but I see a more correct delegation of the method of creating objects of the User
class to the UserService
service UserService
same way as he is responsible for receiving a list of users.
// UserService import {Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {Observable} from 'rxjs'; import {map} from 'rxjs/operators'; import {UserServerResponse} from './user-server-response.interface'; import {User} from './user.model'; @Injectable() export class UserService { constructor(private http: HttpClient, private storage: StorageService, private auth: AuthService) { } createUser(userData: UserServerResponse) { return new User(userData, this.http, this.storage, this.auth); } getUsers(): Observable<User[]> { return this.http.get<UserServerResponse[]>('/users') .pipe(map(listOfUsers => listOfUsers.map(singleUser => this.createUser(singleUser)))); } }
Thus, we moved the user creation method to the UserService
, which is now more appropriately called a factory, and shifted all the work on dependency injection onto Angoulard’s shoulders — we only need to connect the UserService
in the constructor.
Finally, let's remove duplication from method names and introduce conventions on the names of the dependencies being injected. The final version of the service in my vision should look like this.
import {Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {Observable} from 'rxjs'; import {map} from 'rxjs/operators'; import {UserServerResponse} from './user-server-response.interface'; import {User} from './user.model'; @Injectable() export class UserFactory { constructor(private http: HttpClient, private storage: StorageService, private auth: AuthService) { } create(userData: UserServerResponse) { return new User(userData, this.http, this.storage, this.auth); } list(): Observable<User[]> { return this.http.get<UserServerResponse[]>('/users') .pipe(map(listOfUsers => listOfUsers.map(singleUser => this.create(singleUser)))); } }
And the component UserFactory
proposed to be implemented under the name User
import { Component } from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {UserFactory} from './services/user.service'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent { title = 'app'; users = this.User.list(); constructor(private User: UserFactory) { } }
In this case, the UserFactory
class object looks like a User
class with static methods for getting a list of users and a special method for creating new entities, and its objects contain all the necessary business logic methods associated with a particular entity.
At this point I told everything I wanted. I look forward to the discussion in the comments.
I wanted to express my gratitude to all those who comment. You rightly noticed that to solve the problem with the display of the name would be to use the Pipe
. I completely agree and I am surprised why I did not give this decision. Nevertheless, the main purpose of the article is to show an example of creating a domain model (in this case, a User
), which could conveniently encapsulate all the business logic associated with its essence. At the same time I tried to solve the accompanying problem with dependency injection.
Source: https://habr.com/ru/post/418463/
All Articles