top of page

SNIPPETS LTD.

Angular Anti-Patterns 01 Cleaning the Stateful Services

  • Writer: Pavol Megela
    Pavol Megela
  • Nov 2, 2022
  • 6 min read

Updated: Mar 8

Before exploring the anti-pattern, note that the example use Angular's signals, a new reactive primitive for state management that simplifies change detection. Learn more in the Angular Signals Guide.


Common UseCase and Its Pitfalls

When you start building an Angular app, almost every application needs a login functionality. In many cases you create a service that handles the login and then keeps the user state for the rest of the application. This user state is then used to display the username, email, enable logged in guards etc.


In the simplest implementation you might end up with something like this:

interface User {
  userId: string | null;
  username: string | null;
  email: string | null;
}

@Injectable()
export class UserService {
  private readonly _user: WritableSignal<User>;

  public get user(): Signal<User> {
    return this._user;
  }

  public constructor() {
    this._user = signal({
      userId: null,
      username: null,
      email: null
    });
  }

  public async login(login: string, password: string): Promise<void> {
    const {userId, username, email} = await fetch('v1/authentication/login', {
      method: 'POST',
      body: JSON.stringify({login, password})
    });

    this._user.set({
      userId,
      username,
      email
    });
  }
}

If you’re unfamiliar with signals, you can read more about them in the Angular Signals Guide.


What’s Going on Here

The code above is pretty straightforward. The UserService initializes a signal to hold the user data. When the login method is called it sends a POST request to the login endpoint, extracts the returned user properties, and updates the user signal. This service is then used throughout the app to check if the user is logged in, display their name or email, and control route access with guards.


The Hidden Anti-Pattern

At first glance, this implementation looks fine – it’s simple and does the job. But if you look closer you’ll notice a couple of issues. Basically, we just created an anti-pattern. If you didn’t see it, keep reading.


Mixing Layers

This code is mixing the application layer with the infrastructure layer. The UserService not only handles the business logic (user login state) but also directly communicates with the backend using fetch. This means the service is responsible for both application logic and handling the actual API call (infrastructure). When these layers mix, it makes testing, maintenance, and future scalability a headache.


Breaking Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) is all about making sure that a class or module does one thing and does it well. Here, the service is doing two things:

  • Managing user state

  • Handling the API call (infrastructure concerns)


That means if something goes wrong with the login request or the API changes, you may need to modify the same service that handles your application’s state, which is not ideal. However, if you know exactly what you’re doing, you might start simple and refactor later – but it can become a bigger problem as the application grows.


How to Fix It

A better approach is to split the code into an infrastructure service and an application service. Here’s how you can do that:


  1. Split Responsibilities


Infrastructure layer

Start by creating a dedicated infrastructure service that deals only with the API calls. This service will have the sole responsibility of talking to the backend. In parallel, have an application service that depends on an abstraction of that infrastructure service. This abstraction lets you switch implementations easily (for example when writing tests or even swapping out the backend without touching your core business logic).


Also don't forget to create a separate model to map the API response from the backend. This model might look similar to your domain object but allows for changes in the backend response without affecting your core business logic.

Then the Infrastructure service might look something like this (and of course in a real world application you want to have type saved in a separate file)

export interface UserResponse {
  userId: string;
  username: string;
  email: string;
}
@Injectable()
export class UserApiService {
  public async login(login: string, password: string): Promise<UserResponse> {
    const response = await fetch('v1/authentication/login', {
      method: 'POST',
      body: JSON.stringify({ login, password })
    });
    return response.json();
  }
}

Application layer

Now, let’s expand the Application Service. This service not only manages the state but also acts as the mediator between the UI and the infrastructure service. By doing so, it enforces that the application layer only depends on an abstraction of the infrastructure layer rather than directly calling external services.

export interface User {
  id: string;
  name: string;
  email: string;
}
@Injectable()
export class UserService {
  private readonly _user: WritableSignal<User>;  
  
  public constructor(private userApiService: UserApiService) {
    this._user = signal({
      id: null,
      name: null,
      email: null
    });
  }

  public get user(): Signal<User> {
    return this._user;
  }

  public async login(login: string, password: string): Promise<void> {
    const userResponse = await this.userApiService.login(login, password);
    const user: User = {
      id: userResponse.userId,
      name: userResponse.username,
      email: userResponse.email
    };

    this._user.set(user);
  }
}

State Management & Business Logic:

The UserService now focuses on managing the user state and transforming raw data from the API into a domain model that your application understands. By isolating this logic, you have a single source of truth for how user data is stored and manipulated.


Layered Architecture Example:

Imagine a scenario where your application needs to support multiple login mechanisms (e.g., OAuth, SAML). If the application service depended directly on an API call, every new mechanism would require modifying the core logic. Instead, by depending on an abstraction (the UserApiService), you can swap out implementations without touching your business logic. For example, in testing, you can provide a mocked version of UserApiService that returns predetermined responses. This separation simplifies maintenance and scalability as your application grows.


Dependency Injection & Abstraction:

Notice how UserService receives UserApiService via dependency injection. This pattern allows you to abstract away the details of how data is fetched. The application service remains agnostic to the specific implementation of the infrastructure layer, thus adhering to the Dependency Inversion Principle (DIP).


But the refactoring is not quite done yet ....


Now you might think we’re done with refactoring, but there’s another caveat: the Application Layer should not depend directly on the Infrastructure Layer. In other words, higher layers should depend on abstractions rather than concrete implementations. This means that rather than having the Application Service directly reference the UserApiService, it should depend on an interface that the infrastructure service implements. To help you understand this let's look at the code, you'll define an interface for the user authentication API:

export interface UserAuthApi {
  login(login: string, password: string): Promise<UserResponse>;
}

Then, update your Application Service to depend on this abstraction:

@Injectable()
export class UserService {
  private readonly _user: WritableSignal<User>;

  public constructor(private userAuthApi: UserAuthApi) {
    this._user = signal({
      id: null,
      name: null,
      email: null
    });
  }

  public get user(): Signal<User> {
    return this._user;
  }

  public async login(login: string, password: string): Promise<void> {
    const userResponse = await this.userAuthApi.login(login, password);
    const user: User = {
      id: userResponse.userId,
      name: userResponse.username,
      email: userResponse.email
    };
    this._user.set(user);
  }
}

Finally, ensure that your concrete Infrastructure Service implements the UserAuthApi interface:

@Injectable()
export class UserApiService implements IUserApi {
  public async login(login: string, password: string): Promise<UserResponse> {
    const response = await fetch('v1/authentication/login', {
      method: 'POST',
      body: JSON.stringify({ login, password })
    });
    return response.json();
  }
}

This design ensures that your Application Service is not tightly coupled to a specific API implementation. It promotes better testability and flexibility since you can easily substitute the infrastructure layer with a different implementation or a mock for testing purposes.


The Benefits

Modularity

Changes in the API or switching to another communication method won’t force changes in your business logic.


Testability

Unit tests can easily mock the UserApiService to simulate various backend responses, making the application service easier to test in isolation.


Scalability

As your application evolves, adding features like error handling, caching, or alternative login strategies becomes more manageable because each responsibility is clearly separated.


Clear Layer Separation The application layer no longer directly depends on the infrastructure. Instead, it depends on an abstraction, making your codebase more modular and testable


SRP is Respected: Your application service now only manages business logic, while the infrastructure service handles communication with external systems.


Production Tips

Store State in a SignalStore: Instead of storing user state directly in the UserService, consider using a dedicated SignalStore. This provides a single source of truth for your state and makes it easier to manage reactive data across your app.


Use Dependency Injection for Abstractions: Always inject the abstraction of your infrastructure service. This decoupling helps in maintaining and testing your application.


Plan for Scalability: Even if your application is small now, designing your services with clean separations will save you headaches when the app grows.


Refactor When Needed: If you’re just starting out and you know what you’re doing, it might be okay to write something like the initial example. Just remember that as the application grows, refactoring to separate concerns is a must.


By following these guidelines you can avoid common pitfalls and keep your Angular application maintainable and scalable. The key is to respect the architecture layers and keep your responsibilities well-defined.

Comentarios


bottom of page