Utility as a public or protected method

See Utils and Services about our standard concept.

Why not implement directly inside the actual Foo class as protected method?

This is a topic that requires better explanation.

However, for short, OO abstraction is overly complex and erroneous concept. It has its use cases, probably.

Why not implement directly inside the actual Foo class as public method?

In the end our way is just an architectural design choice. It might look useless in a small and minimal application.

However, for larger projects with multiple people working on the same code, these small architectural choices are much more important, and may even have an impact on how the project will succeed and how often git conflicts are made.

So, what is the point of a utility class? In the end, like most architectural choices, it's just a way to divide different parts of the code into smaller files.

Let me explain with some examples from (almost) real world applications.

Let's assume you're developing a frontend application that uses a backend. You get the specification which kind of JSON the backend uses -- you didn't design it, it was made by another team or even another company.

First we define an interface UserDTO. It describes an common interface for any user JSON object which comes from the backend. TypeScript makes it easy to define types for any JSON object.

export interface UserDTO {
    readonly firstName : string;
    readonly lastName  : string;
    readonly isAdmin   : boolean;
}

export function isUserDTO (value : any) : value is UserDTO {
    return (
      !!value 
      && isString(value?.firstName) 
      && isString(value?.lastName) 
      && isBoolean(value?.isAdmin)
    );
  }

Then we define an interface UserModel. It describes a common interface for our internal user objects which are used inside our application.

export interface UserModel {
    getFirstName()   : string;
    getLastName()    : string;
    getDisplayName() : string;
    isAdmin()        : boolean;
}

Take a note, interfaces cannot include these static methods which we define next in UserUtils.

Then you would define UserUtils. These static functions work with any object implementing the User interface, and may contain functions to parse backend responses. Unless the interface changes, your team does not have to make changes to these functions when your internal implementations are changed or new ones are created.

export class UserUtils {

    public static parseUserDTO (value: any) : UserDTO {
      if (!isUserDTO(value)) throw new TypeError('Argument was not UserDTO');
      return value;
    }

    public static isAdminUserDTO (value: UserDTO) : boolean {
      return value.isAdmin;
    }

    public static getFullNameFromDTO (model: UserDTO) : string {
      return model.firstName + ' ' + model.lastName;
    }

    public static getFullNameFromModel (model: UserModel) : string {
      return model.getFirstName() + ' ' + model.getLastName();
    }

  }

Then you may have one or more different user classes. Like AdminUserModel and CustomerUserModel, etc. These all implement the same UserModel interface, but different features. These classes may even use UserUtils implementations to implement common methods. There are no circular dependency problems.

export class CustomerUserModel implements UserModel {

    private readonly _firstName : string;
    private readonly _lastName : string;

    constructor (firstName: string, lastName: string) {
      this._firstName = firstName;
      this._lastName = lastName;
    }

    public getFirstName () : string {
      return this._firstName;
    }

    public getLastName () : string {
      return this._lastName;
    }

    public getDisplayName () : string {
      return UserUtils.getFullNameFromModel(this);
    }

    public isAdmin() : boolean {
      return false;
    }

}

export class AdminUserModel implements UserModel {

    private readonly _firstName : string;
    private readonly _lastName : string;

    constructor (firstName: string, lastName: string) {
      this._firstName = firstName;
      this._lastName = lastName;
    }

    public getFirstName () : string {
      return this._firstName;
    }

    public getLastName () : string {
      return this._lastName;
    }

    public getDisplayName () : string {
      return UserUtils.getFullNameFromModel(this);
    }

    public isAdmin() : boolean {
      return true;
    }

}

Finally, this is how to handle a response from the backend:

export class UserService {

    public static async fetchUser (id : string) : Promise<UserModel> {
      const response : HttpResponse<UserDTO> = await HttpService.get(USER_API_URL(id));
      return this._parseUserData(response?.data);
    }

    private static _parseUserData (dtoJson: any) : UserModel {

      const dto : UserDTO = UserUtils.parseUserDTO(dtoJson);

      if (UserUtils.isAdminUserDTO(dto)) {
        return new AdminUserModel(dto.firstName, dto.lastName);
      } else {
        return new CustomerUserModel(dto.firstName, dto.lastName);
      }

    }

}

So in the end these architectural choices are just decoupling different parts of the software into different blocks in order to let multiple developers easily maintain and work on the same source code separately in effective way.

It also reduces duplicate source code lines in the application, which may reduce bugs, since the fix will not be forgotten to be applied to a duplicated code somewhere else in the app.