Full-stack app with Angular, NestJS, and Docker feature image

Full-stack app with Angular, NestJS, and Docker

In this article, I will share my experience building a containerized full-stack application using NestJS and MySQL on the backend with Angular on the frontend. Since the project is taken from a Udemy course, there will be some feedback on the course itself.

First of all, I don't like to blindly follow tutorials, and I always try and put a bit of a spin on them. This can complicate things, but it also forces me to think, research and better understand the technology I'm dealing with. In this particular course, I didn't like the way the UI looked as the mentor user a generic Bootstrap template for it. I thought I'd make it a bit easier on the eye and decided to use Angular Material. This, of course, caused quite a few problems as I have never used it before, and it can be a bit more complicated to integrate than a plain Bootstrap theme or template.

I will touch upon all the issues I ran into and the solutions I found.

The course starts with setting up Docker, Nest, installing dependencies, and moving on to building the backend. I made a few changes to the docker-compose file and ran into some issues. First of all, I used the latest version of MySQL. As a result, I wasn't able to access the database from the console, so I added PhpMyadmin to the docker-compose file. I also moved all the sensitive information like passwords to .ENV file.

One thing I noticed about using docker is that it's very slow on my crappy old laptop running on Windows. It takes forever to start, connect to the DB, and compile the Nest app. I mean, it takes well over 5 mins. Now granted, I didn't try to set up the development environment under WSL properly. It takes pretty long to recompile on changes as well. Yet, I still quite liked the setup and planning on using it in the future.

I did build some apps with Express in the past so using Nest wasn't that much of a difference. I liked the fact that Nest enforces a certain structure and gives quite a bit of functionality out of the box. For example, I like how you can throw different kinds of exceptions, which is pretty similar to .NET.

One thing I couldn't understand is why the mentor used snake case when naming fields in data entities(models). I didn't like that idea and stuck to the camel case only to find out the reason for using the snake case. MySQL lowercases all the table names by default. I found out that I could disable this behavior by adding

command: --lower_case_table_names=0

to the database configuration in the docker-compose file.

It worked, however, it started complaining about the file system not being case-sensitive. So I gave up and switched to snake case.

I'm not a huge fan of is his implementation of server-side pagination where we hard-coded several entries per page. The data looks something like this:

{
    data: [
        // array of objects
    ],
    meta: {
        total: 17 //total number of entries
        page: 1 //current page
        lastPage: 2
    }
}

This approach is not very flexible, and I don't think it works very well with Angular Material. I prefer using query strings.

Another thing that I found quite of a bummer is handling file uploads to the server. The mentor teaches you how to upload files, define file extension, generate a random name, etc. This is useful and interesting, however, I found that an important piece of information is missing as he never teaches you how to delete these files or manipulate them in some way later on. I'm saying important because it seems quite a bit more difficult to implement(with this setup) and when I looked online I couldn't find any decent information on how to do it with Nest/Multer etc. I can't say that I've researched this topic thoroughly though.

Otherwise, the tutorial was pretty enjoyable to follow and easy to understand. There isn't much to say in terms of the coding itself as all these projects are quite similar, and there are many articles, videos, and courses available on the Internet.

The frontend part wasn't too difficult for me as I built Angular apps in the past and I was already familiar with the framework. Using Angular Material added that extra challenge for me to not get bored.

I decided to stick with reactive forms and added some custom validations. Angular Material comes with form field validation errors. You can use them by nesting <mat-error> element in a <mat-form-field>. You also need to write some logic that will return a message when a form field has an error state. It's pretty straight-forward. When a field fails validation the error message is displayed. Like this:

<mat-error *ngIf="this.registerForm.controls.password_confirm.invalid">
  {{ getPasswordErrorMessage() }}
</mat-error>

I created validation for all of the fields and created an HTTP error message that is displayed under the form header. My code is not the driest, and perhaps it can be simplified. I'm aware that it's possible to set up a global error handler that can dry up the code, but I didn't feel like doing the research this time. In any case, this is the implementation that I pretty much copy-pasted in all of the forms (there weren't that many):

export class RegisterComponent {
  registerForm: FormGroup;

  isLoading: boolean;

  hasHttpError: boolean;

  httpErrorMessage: string;

  constructor(
    private formBuilder: FormBuilder,
    private authService: AuthService,
    private router: Router
  ) {
    this.isLoading = false;
    this.hasHttpError = false;
    this.httpErrorMessage = '';
    this.registerForm = this.formBuilder.group({
      first_name: ['', [Validators.required, Validators.pattern('[a-zA-Z ]*')]],
      last_name: ['', [Validators.required, Validators.pattern('[a-zA-Z ]*')]],
      email: ['', [Validators.required, Validators.email]],
      password: ['', Validators.required],
      password_confirm: ['', [Validators.required]],
    });

    this.getEmailErrorMessage = this.getEmailErrorMessage.bind(this);
  }

  getFirstNameErrorMessage() {
    if (this.registerForm.controls.first_name.hasError('required')) {
      return 'Enter your name';
    }
    return this.registerForm.controls.first_name.hasError('pattern')
      ? 'Last name can only contain letters'
      : '';
  }

  getLastNameErrorMessage() {
    if (this.registerForm.controls.last_name.hasError('required')) {
      return 'Enter your last name';
    }
    return this.registerForm.controls.last_name.hasError('pattern')
      ? 'Name can only contain letters'
      : '';
  }

  getEmailErrorMessage() {
    if (this.registerForm.controls.email.hasError('required')) {
      return 'Enter your email';
    }

    return this.registerForm.controls.email.hasError('email')
      ? 'Not a valid email'
      : '';
  }

  getPasswordErrorMessage() {
    if (this.registerForm.controls.password.hasError('required')) {
      return 'Enter password';
    }
    if (this.registerForm.controls.password_confirm.hasError('required')) {
      return 'Confirm your password';
    }
    return this.registerForm.controls.password_confirm.hasError(
      'passwordMismatch'
    )
      ? 'Passwords do not match'
      : '';
  }

  validateOnSubmit(): void {
    const comparePasswords = (): void | AbstractControl => {
      if (
        this.registerForm.get('password')?.value !==
        this.registerForm.get('password_confirm')?.value
      ) {
        return this.registerForm.controls.password_confirm.setErrors({
          passwordMismatch: true,
        });
      }
    };
    comparePasswords();
  }

  submit(): void {
    this.validateOnSubmit();
    const formData: RegisterForm = this.registerForm.getRawValue();
    if (this.registerForm.valid) {
      const registerUser = this.authService
        .registerUser(formData)
        .pipe(
          catchError((err: HttpErrorResponse) => {
            if (err.error instanceof ErrorEvent) {
              console.error('An error occurred:', err.error.message);
            } else {
              console.error(
                `Backend returned code ${err.status}, ` +
                  `body was: ${JSON.stringify(err.error)}`
              );
              err.error.message
                ? (this.httpErrorMessage = err.error.message)
                : (this.httpErrorMessage = `Backend returned code ${err.status}`);
            }
            this.isLoading = !this.isLoading;
            this.hasHttpError = !this.hasHttpError;
            return throwError(
              `Couldn't send data to the server; please try again later.`
            );
          })
        )
        .subscribe(() => {
          this.router.navigate(['/login']);
          registerUser.unsubscribe();
        });
      this.isLoading = !this.isLoading;
    }
  }
}

isLoading here is used to display a spinner.

Angular Material comes with some cool features like the ability to generate components using schematics and Angular CLI. For example, I generated the layout of my app with an app bar, sidebar, and content area by running this command ng generate @angular/material:navigation <component-name>. Everything is fully responsive out of the box and is fully customizable. It uses a feature called BreakpointObserver to evaluate media queries and adjust the layout according to a set of predefined breakpoints. I didn't spend much time playing around with it but it seems highly customizable. Check out their website for more information. If you want to find out how to set it up and customize it, Maximillian Schwarzmueller has some good videos on it.

I think that table schematics is one of the best features of Angular Material. Tables can be generated from the console, and they include a data source, sortable headers, and customizable paginator. All you have to do is to plug your data into the table's data source, add some rows, change up the sorting function a bit, and there you go. It sounds easy and it works great with static data but sadly there is little information on how to get and handle data from the backend. After looking around and reading through a bunch of different articles, I decided to stick with this solution:

  paginator!: MatPaginator;

  sort!: MatSort;

  private usersSubject = new BehaviorSubject<any[]>([]);

  public metaSubject = new BehaviorSubject<{}>({});

  constructor(private userService: UserService) {
    super();
  }
  connect(collectionViewer: CollectionViewer): Observable<any[]> {
    return this.usersSubject.asObservable();
  }

  disconnect(collectionViewer: CollectionViewer) {
    this.usersSubject.complete();
  }

  loadUsers(pageIndex = 0) {
    this.userService
      .getAll(pageIndex)
      .subscribe((users) => {
        const data = this.getSortedData(users.data);
        this.sort.sortChange.pipe(
          map(() => {
            return data;
          }),
        );
        this.usersSubject.next(data);
        this.metaSubject.next(users.meta);
      });
  }

  private getPagedData(data: any) {
    const startIndex = this.paginator.pageIndex * this.paginator.pageSize;
    return data.splice(startIndex, this.paginator.pageSize);
  }

  getSortedData(data: User[]) {
    if (!this.sort.active || this.sort.direction === '') {
      return data;
    }

    return data.sort((a: User, b: User) => {
      const isAsc = this.sort.direction === 'asc';
      switch (this.sort.active) {
        case 'name':
          return compare(a.first_name, b.first_name, isAsc);
        case 'email':
          return compare(a.email, b.email, isAsc);
        case 'role':
          return compare(a.role.name, b.role.name, isAsc);
        case 'id':
          return compare(+a.id, +b.id, isAsc);
        default:
          return 0;
      }
    });
  }

and this:

  handleSort() {
    this.dataSource.loadUsers(this.paginator.pageIndex + 1);
  }

  ngOnInit() {
    this.dataSource = new UsersTableDataSource(this.userService);
    this.dataSource.loadUsers(1);
    this.dataSource.metaSubject.subscribe((data) => (this.meta = data));
  }

  ngAfterViewInit() {
    this.dataSource.sort = this.sort;
    this.paginator.page.pipe(tap(() => this.loadUsersPage())).subscribe();
    this.table.dataSource = this.dataSource;
  }

  loadUsersPage() {
    this.dataSource.loadUsers(this.paginator.pageIndex + 1);
  }

Don't judge me too much for this crappy code as this is my first time using the library and I'm still not very confident with RxJs. I did, however, learn quite a bit through reading a bunch of articles and docs on RxJs, which helped me write better code towards the end of the tutorial.

The problem with this setup is that it hits up the database every time you go to a new page or sort the data. loadUsers method sends a GET request to the database, passing the current paginator index(current page) as an argument. I have to add +1 because the paginator uses zero-based indexing while the backend uses one-based indexing. To be fair, this is not how the mentor implemented it, and I think his version could also work here. This is where using query strings would be a lot better. We could request all the data from the backend, push it into an empty array, and then we could take full advantage of all the features Angular Material tables have to offer. But that would require rewriting the backend code or looking for some weird workarounds, so I left it as is. This is some food for thought for the next time I'll use Angular Material though.

Generally, I think there must be a better way to write the logic to receive, update and manipulate the data. It seems like there are quite a few cases where more or less data is shared across different components. So maybe it would be better to use a state management library. There are many subscriptions, and some of them are used to emit events via EventEmitters, which I'm also not sure is an ideal solution. The course could also benefit from a section dedicated to the proper management of subscriptions. Observables can be a bit tricky to wrap your head around if you haven't dealt with them before. As far as I understand, it's considered a good practice to unsubscribe from all subscriptions when a component is destroyed. In this tutorial, however, it's never done as the OnDestroy lifecycle is never used. I suspect it could be because the HttpClient closes the data stream automatically when the data comes through. But in some scenarios that could cause a memory leak. For example when we pass the data to one of the components parameters:

// ...
users: User[] = [];
// ....
this.userService.get().subscribe((res)=> this.users = res);

Such a scenario is used throughout the tutorial. This is why I find the fact that the unsubscribe method is never used or even discussed quite problematic.

It's a good idea for anyone who takes this course to spend more time researching some information on memory leaks in Angular, RxJS, subscriptions, etc. Tomas Trajan has some helpful information on his blog.

One other thing I forgot to mention is the same issue I see in most similar tutorials. From what I've experienced so far, in such long-winded tutorials, to put it bluntly, they overpromise and underdeliver. What I mean is mentors always demonstrate some cool app with several features that you'll be implementing but they usually run out of steam towards the end leaving you without some of the features or with a half-assed implementation thereof. It was the case with the famous Colt Steele's course as well (he recently released a new version and maybe some of the issues were addressed). In this course, we are building some sort of e-commerce type admin panel to monitor sales or something like that. There are several types of users with different roles and different permissions(access levels). There are also products and orders. We build full CRUD functionality for the users, roles, and products but the orders seem to magically appear out of nowhere. It's kind of a similar issue to the one with image upload. Now, I understand that the author wanted to demonstrate all the cool features like exporting CSV files and building charts but I do think that it's better to leave a feature out rather than delivering some half-baked solution.

Of course, if you make it that far in the tutorial, you should probably be able to implement this functionality yourself if you choose so. But I still think it doesn't belong in the tutorial. It would probably better to turn it into some sort of assignment that whoever takes the course can do on their own.

In conclusion, I'd like to say that I don't want to come off as complaining too much. This is not the case as I enjoyed the course and would recommend taking it. However, there are some issues to keep an eye out for.

My code is available here.

BACK TO BLOG