Isolating user data logic with a UserService
Cezar Pleșcan
Posted on June 20, 2024
Introduction
In this article, I'll shift the focus to refactor the user data management. Currently, the logic for fetching and saving user data is handled within the component. To create a cleaner, more maintainable architecture, I'll extract this logic into a dedicated service, named UserService
.
A quick note:
- Before we begin, please note that this article builds on concepts and code introduced in previous articles of this series. If you're new here, I highly recommend that you check out those articles first to get up to speed.
- The starting point for the code I'll be working with in this article can be found in the
15.http-rxjs-operators
branch of the repository https://github.com/cezar-plescan/user-profile-editor/tree/15.http-rxjs-operators.
What I'll cover
In this article, I'll guide you through a step-by-step refactoring process to create a dedicated UserService
and enhance our Angular application architecture:
-
Analyzing the
UserProfileComponent
: Pinpoint specific areas where data access and manipulation logic can be separated from UI concerns. -
Creating the
UserService
: Generate a dedicated service to handle all interactions with the user data API. -
Handling form data: Introduce a helper service,
HttpHelperService
, to convert raw form data into the format required for API requests. - Eliminating hardcoded URLs: Replace hardcoded URLs with dynamic configuration using Angular environment files.
-
Managing API paths: Establish a clear pattern for organizing and maintaining API paths within the
UserService
. - Addressing the User ID challenge: Explore strategies for dynamically determining the user ID without hardcoding it in the service.
By the end of this article, you'll have a deeper understanding of how to structure your Angular applications to achieve better separation of concerns, improved maintainability, and more reusable code.
Identifying the problem
The component handles a wide range of tasks, including performing HTTP requests, processing responses, and managing UI interactions. This approach violates the Single Responsibility Principle (SRP), a fundamental principle of software design that states that a class should have only one reason to change. In our case, the component is doing too much!
Let's take a closer look at the HTTP related methods in the component class:
-
loadUserData()
- fetches user data from the server -
getUserData$()
- defines the observable stream for fetching user data -
saveUserData()
- saves updated user data to the server -
saveUserData$()
- defines the observable stream for saving user data
The getUserData$()
and saveUserData$()
methods use the Angular HttpClient
service to interact with the backend API:
These methods not only return observables but also contain hardcoded endpoint URLs. Maintaining these URLs within the component could become a headache if they change in the future. Therefore, these methods are prime candidates for extraction into a separate service class.
But what about loadUserData()
and saveUserData()
? They contain even more logic.
Should we move them into the service too? The answer is no, because they simply respond to requests, describing how the component UI elements will interact with the received data. These methods are tightly coupled to the component's UI. They handle aspects like: setting UI flags, updating form values, or displaying error messages. These tasks are directly related to the presentation layer and user interaction, which should ideally remain within the component's responsibilities.
I'll extract the reusable, data-centric logic, that is, HTTP requests, into a separate service, while keeping the UI-specific actions within the component. This adheres to the SRP, making the codebase more maintainable and easier to reason about.
In the next section, I'll create the UserService
class to house the HTTP request logic, leaving the loadUserData()
and saveUserData()
methods in the component to manage the UI interactions based on the service's responses.
Creating the UserService
I'll create a service file user.service.ts
in the src/app/services
folder using the Angular CLI command ng generate service user
, then move getUserData$()
and saveUserData$()
methods into it.
Initial content of the service class
Here is the content of the UserService
class, after simply moving the code from the component:
There are 2 errors here:
-
TS2304: Cannot find name 'UserDataResponse'
- this is because the typeUserDataResponse
was defined within the component; I'll address this by creating a shared file for user data types. -
TS2339: Property 'form' does not exist on type 'UserService'
- this error arises because thesaveUserData$()
method references theform
property, which belongs to the component; I'll resolve this by carefully considering where the responsibility for preparing form data should reside.
Let's see how they can be addressed.
Creating shared user data types
To resolve the first error, I'll create a dedicated file for user related type definitions. I'll name this file user.type.ts
and place it in the src/app/shared/types/
folder:
user.service.ts
file I'll import the UserDataResponse
type:import { UserDataResponse } from "../shared/types/user.type";
This solves the first error. Let's tackle the second one now.
Delegating form data preparation
The second error is because the form
property doesn't exist in the new service. This makes sense, as the form is created and managed by the component. But how do we get the form data to the service for the HTTP request?
This situation raises an important design question: where should the responsibility of processing the form data lie?
My goal is to adhere to the Single Responsibility Principle (SRP) by grouping related functionalities and extracting them into separate entities, allowing the component to focus on its core responsibility: managing the view, as intended by the Angular team. I'll show you my approach for this situation.
I'll start by analyzing the different players involved in data manipulation and determine where this data processing should occur. I can identify 4 main actors in this process:
- component - it creates and manages the raw form values.
- raw form values - these are the data entered by the user into the form fields.
- service - it's responsible for making the HTTP requests to save the user data.
-
processed form data - this is the user data transformed into a
FormData
object, suitable for sending in an HTTP request.
The data flow can be visualized like this: Component ==> Form Values ==> Processed Form Data ==> Service ==> HTTP Request.
The raw form values originate within the component and are manipulated by the user through the view. Logically, I can consider these values as an integral part of the component itself.
The processed form data, however, is necessary for the UserService
to create the HTTP request. Where should the transformation from raw values to FormData
object take place? Let's consider the options:
- within the component: This would mean the component would be responsible for both managing the view and preparing the data for the request. This would violate the SRP and make the component less focused.
-
within the service: This seems more appropriate. The
UserService
would receive the raw form values from the component, process them into aFormData
object, and then use it for the HTTP request. However, if we ever need another service that requires a similar data transformation, we'd have duplicate code, which isn't ideal. -
a separate entity: This approach is the most flexible and reusable. I could create a helper function or separate service dedicated to generating the
FormData
object. This would allow us to reuse this logic in different parts of our application.
The remaining question is who should invoke the helper function that generates the FormData
object: the component or the service?
- the component: If the component calls the function, it would mean it still has some knowledge about how the data is prepared for the request. This could lead to tight coupling and make the component less reusable.
- the service: Having the service invoke the helper function is a better approach. This way, the component can simply provide the raw form values, and the service takes care of the rest, including data transformation and sending the request.
In the spirit of separation of concerns, it's more appropriate for the service to invoke the helper function. Here's why:
- data ownership: the service is responsible for communicating with the backend, and the format of the request data is closely tied to this responsibility.
- encapsulation: keeping the data processing logic within the service encapsulates it, preventing the component from being burdened with unnecessary details.
- testability: having the service handle data processing makes it easier to write unit tests for both the component and the service independently.
-
flexibility: this approach allows us to potentially reuse the helper function in other scenarios where we need to prepare the
FormData
for HTTP requests.
By delegating the data processing responsibility to another entity, I create a cleaner architecture where each entity focuses on its core function. The component handles the view and user interactions, while the services take care of data preparation and communication with the backend.
Implementation of the helper service
Let's see this in practice. I'll create a new service dedicated for HTTP related operations, named HttpHelperService
, in the src/app/services
folder, using the Angular CLI: ng generate service http-helper
. Here is its content:
Update the UserService
class
Here is the updated UserService
class that uses HttpHelperService
:
Update the component class
There are a couple of changes to be made in the user-profile.component.ts
file:
- remove the type definitions at the beginning of the file
- inject
UserService
:private userService = inject(UserService);
- invoke the two methods of the service
- add
this.form.value
as the argument of thethis.userService.saveUserData$
method - remove
getUserData$()
,saveUserData$()
, andgetFormData()
methods if not already done - import the necessary types
Here is the updated component file:
Check out the refactored code
The updated code incorporating the changes made so far can be found in the repository at this specific revision: https://github.com/cezar-plescan/user-profile-editor/tree/58be812de4c5c04d914c12b600cfc9c27f3cee4a. Feel free to explore the repository to see the full implementation details and how the refactored component and services interact.
Dealing with hardcoded URLs
As I've discussed earlier, the hardcoded URLs for the API endpoints within the UserService
are less than ideal due to their lack of flexibility, maintainability challenges, and potential security risks. I'll delve deeper into how to solve these issues using Angular's environment files.
Why hardcoded URLs are problematic
- environment specificity: The hardcoded URL ties our service to a specific environment (e.g., http://localhost:3000). If we deploy our application to a different server or domain, the URL would be incorrect, and our service would break.
- configuration changes: If the base URL for our API changes (e.g., due to server migration or a change in the API version), we'll need to manually update it in every service where it's used, which is error-prone and tedious.
- scattered configuration: Having URLs scattered throughout our services makes it harder to manage and update them consistently. It becomes a challenge to keep track of all the places where the URL is used, increasing the risk of errors when changes are needed.
- code duplication: If multiple services use the same base URL, we'll likely have duplicate code, violating the DRY (Don't Repeat Yourself) principle.
- testing challenges: When testing the service, we'll need to mock the API endpoints. Hardcoded URLs can make it more difficult to replace the real API with a mock during testing.
To tackle these issues, let's take a closer look at the structure of our URLs and discuss who should be responsible for managing them.
Understanding the URL structure and responsibilities
It's important to understand how our URLs are put together. Let's break down the URL we're using, http://localhost:3000/users/1
:
-
Base URL: This is the main address of our API (in this case,
http://localhost:3000
). This can vary depending on where the app is running (our computer, a testing server, or the live server). We shouldn't hardcode this in our service. -
API Path: This is the part of the URL that tells the server we want to work with users (
/users
). This should be the service's responsibility. -
User ID: This is the specific user we're dealing with (in this case, user number
1
). The user ID might be a fixed value (e.g., when an admin updates a user's data) or represent the currently logged-in user. In the latter case, another service, like anAuthService
, should be responsible for managing it.
Understanding this structure helps us determine who should build the complete URL and where each part should be defined.
Managing the base URL
The best way to manage the base URL is to use environment files. We'll have different files for different environments (development, testing, production). This way, we can easily change the base URL without touching our service code.
Creating the environment files
Angular's environment files are a powerful mechanism for managing configuration settings across different environments. They allow us to define variables specific to each environment, keeping our codebase adaptable and maintainable.
Angular comes with a dedicated command for creating these files: ng generate environments
. This will create two files in the src/environments
folder: environment.ts
and environment.development.ts
. At their core, environment files are simply TypeScript files that export a constant object containing our configuration variables.
Add the following content to the environment.development.ts
file:
Since we have only one environment right now, I won't use the environment.ts
file.
To gain a deeper understanding of how environment files work and how to leverage them effectively in your Angular projects, you can explore the following resources:
- Angular Environment Variables (YouTube) - This video tutorial provides a step-by-step walkthrough of setting up and using environment files in Angular.
- Angular Environment Variables - This DigitalOcean tutorial offers a comprehensive guide on configuring and using environment variables in Angular projects.
- Angular Basics: Using Environmental Variables to Organize Build Configurations - This Telerik blog post explores the fundamentals of environment variables in Angular and how they can help organize your build configurations.
Usage in the UserService
To access the environment variables in our service, we simply need to import the environment.ts
file:
import { environment } from '../../environments/environment';
But earlier I've said that this file is not used! Under the hood, Angular replaces the content of this file with environment.development.ts
file. This happens because angular.json
file was automatically updated with an additional configuration:
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
Now, in the service class, I'll replace the hardcoded URLs with
${environment.apiBaseUrl}/users/1
The /users
path
The /users
part of the URL is specific to our API and how it's set up. Since the UserService
talks to the API, it makes sense for this path to live there. I'll define it as a constant within the service, so it's easy to update if needed.
const USERS_API_PATH = 'users';
`${environment.apiBaseUrl}/${USERS_API_PATH}/1`
Handling the User ID
There are primarily two main approaches to providing the user ID to the UserService
:
- pass as argument: the component, considering it's aware of the current user context, explicitly passes the user ID as an argument when calling service methods like
getUserData$
orsaveUserData$
. - service retrieval: the
UserService
itself fetches the user ID from another source; this source could be route parameters, an authentication service, or even a state management system like NgRx.
It's important to note that, regardless of the method, fetching the user ID isn't the UserService
's primary responsibility.
The best method depends on how the application is structured and how complex it is. In this tutorial, to keep things simple, I'll leave the user ID hardcoded for now, as there's no real context to determine which option is the most suitable. However, in a real-world project, you'd choose the option that best fits your specific needs and keeps your code clean and easy to maintain.
See the code in action
To see all of these changes in action and get a better understanding of how the pieces fit together, check out this specific revision in the repository https://github.com/cezar-plescan/user-profile-editor/tree/308208159bd28d6ff3a5f4959ba6d867c1ae632a.
Conclusion
In this article, we successfully refactored our UserProfileComponent
, tackling the challenge of tightly coupled responsibilities. By extracting the data access and form data preparation logic into dedicated services – UserService
and HttpHelperService
– we've achieved a cleaner and more maintainable codebase.
Here's what we've accomplished:
-
Improved separation of concerns: Our
UserProfileComponent
is now focused on its core responsibility: managing the user interface and interactions. - Enhanced reusability: The extracted services can be easily reused in other parts of our application, saving us time and effort.
- Better code organization: Our project structure is more modular and easier to navigate.
This refactoring effort not only simplifies our current code but also paves the way for future enhancements. With a more streamlined component and reusable services, we can easily add new features or modify existing ones without worrying about unintended side effects.
Feel free to explore and experiment with the code from this article, available in the 16.user-service
branch of the GitHub repository.
I hope this article has shown you the power of refactoring in improving Angular code quality and maintainability. Please leave your thoughts, questions, or suggestions in the comments below! Let's keep learning and building better Angular applications together.
Thanks for reading!
Posted on June 20, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.