The unopinionated nature of React is a two-edged sword:
This article is the fifth part of a series about software architecture and React apps where we take a code base with lots of bad practices and refactor it step by step.
Previously, we created the initial API layer and extracted fetch functions, added data transformations, and separated domain entities and DTOs to isolate our UI code from the server.
But we’re not done yet.
In this article, we’re finalizing the API layer. We will create infrastructure services and use dependency injection where it’s valuable. This way it’ll be easy to unit test data access logic.
Another step towards a cleaner architecture.
Let’s have a look at a problematic code example. Here is a fetch function in the API layer that transforms input/output data and sends an API request.
// src/api/media/api.tsimport { apiClient } from "../client";import { ImageDto } from "./dto";import { dtoToImage } from "./transform";async function uploadImage(file: File) {const formData = new FormData();formData.append("image", file);const response = await apiClient.post<{ data: ImageDto }>("/image", formData);const imageDto = response.data.data;return dtoToImage(imageDto);}export default { uploadImage };
And here is a component that uses the above function. It imports the MediaApi
among others and uploads an image in a form submit handler.
// src/components/shout/reply-dialog.tsxAimport MediaApi from "@/api/media";import ShoutApi from "@/api/shout";import UserApi from "@/api/user";...export function ReplyDialog({ children, shoutId }: ReplyDialogProps) {const [open, setOpen] = useState(false);const [isLoading, setIsLoading] = useState(true);const [isAuthenticated, setIsAuthenticated] = useState(false);const [hasError, setHasError] = useState(false);...async function handleSubmit(event: React.FormEvent<ReplyForm>) {event.preventDefault();setIsLoading(true);try {const message = event.currentTarget.elements.message.value;const files = event.currentTarget.elements.image.files;let image;if (files?.length) {Aimage = await MediaApi.uploadImage(files[0]);}const newShout = await ShoutApi.createShout({message,imageId: image?.id,});await ShoutApi.createReply({shoutId,replyId: newShout.id,});setOpen(false);} catch (error) {console.error(error);} finally {setIsLoading(false);}}// a form using the handleSubmit function is rendered herereturn (...);}
If you’re thinking we should use a library for server state management like react-query instead of manually fetching data in a useEffect: You’re right. But for now let’s stay tool-agnostic. We’ll get to react-query in a future article.
When we started the refactoring we initially created simple fetch functions that had one job: sending API requests. But over time we added more responsibilities. In this case data transformations:
While this isn’t too bad, we started mixing concerns. At the same time this code isn’t very testable as we’d have to mock the apiClient
or set up a mock server e.g. with Mock Service Worker. All for unit testing a bit of logic.
On top of that there’s a good chance that the backend folks have to change the API at some point. They might adjust the response data or introduce breaking changes in a new version of the endpoint. In a larger team with different deploy pipelines and release cycles for the backend and frontend this can be difficult to manage.
In our case, the uploadImage
function is a good place to add the code that switches between the old and the new endpoint (take this as an illustrative example, not something I’d necessarily use in production):
// src/infrastructure/media/api.tsasync function uploadImage(file: File) {const formData = new FormData();formData.append("image", file);if (process.env.API_VERSION === "2") {const response = await apiClient.post<{ data: ImageDto }>("/v2/image", formData);return dtoToImageV2(response.data.data);} else {response = await apiClient.post<{ data: ImageDto }>("/image", formData);return dtoToImage(response.data.data);}}
This is logic that you’d want to test. But again, we’d have to mock the apiClient
or set up a mock server.
The goal is to refactor the code to have separate concerns and simplify writing unit tests. This will take us a few simple steps from
uploadImage
function into a service and API clientLet’s start with the first step.
We start by going back to square one: We remove all logic from the uploadImage
function.
// src/infrastructure/media/api.tsimport { apiClient } from "../client";import { ImageDto } from "./dto";async function uploadImage(formData: FormData) {const response = await apiClient.post<{ data: ImageDto }>("/image", formData);return response.data;}export default { uploadImage };
This function has a single responsibility: Connecting to the server’s REST API. It sends a POST request and returns the response data. This is exactly how we started the refactoring a couple of weeks ago.
Next we move all the logic to a separate file. Since this code is more logic-heavy it probably makes sense to call it a “service”.
// src/infrastructure/media/service.tsimport MediaApi from "./api";import { dtoToImage } from "./transform";async function saveImage(file: File) {const formData = new FormData();formData.append("image", file);const { data: imageDto } = await MediaApi.uploadImage(formData);return dtoToImage(imageDto);}export default { saveImage };
This looks almost like what we had before. But now the service function saveImage
only focuses on data transformation. The implementation details of the API request like path, method, and the exact response data structure aren’t its concern anymore.
Now we still have the same problem with testability though: We’d have to mock the MediaApi
. This is probably a bit easier than mocking the Axios client. But it’s still not ideal.
Dependency injection felt like a mystery to me for a long time. Probably because of all the magic happening in e.g. Angular.
But actually the core principle is quite simple. Let me show you.
So far the media service imports the MediaApi
and calls its uploadFile
function.
// src/infrastructure/media/service.tsAimport MediaApi from "./api";import { dtoToImage } from "./transform";async function saveImage(file: File) {const formData = new FormData();formData.append("image", file);Aconst { data: imageDto } = await MediaApi.uploadImage(formData);return dtoToImage(imageDto);}export default { saveImage };
The service is “in control” of the import and thus has a dependency on the MediaApi
.
Now let’s invert the control. We wrap the service function saveImage
in a class and let it accept the MediaApi
as parameter in its constructor.
// src/infrastructure/media/service.tsAimport { MediaApi } from "./interfaces";import { dtoToImage } from "./transform";Aexport class MediaService {Aconstructor(private api: MediaApi) {Athis.api = api;A}async saveImage(file: File) {const formData = new FormData();formData.append("image", file);Aconst { data: imageDto } = await this.api.uploadImage(formData);return dtoToImage(imageDto);}}
The trick is that the services stores the API instance as a private variable. Then in the saveImage
function this variable is used to call the uploadImage
function.
This is called “Dependency Injection” as we “inject” the MediaApi
dependency into the service. Now the code that instantiates the service is in control of the implementation of the MediaApi
.
As you can see the MediaService
is now independent of the MediaApi
implementation. It doesn’t import MediaApi from "./api"
anymore.
Instead it imports a TypeScript interface: import { MediaApi } from "./interfaces"
This interface looks like below:
// src/infrastructure/media/interfaces.tsimport { ImageDto } from "./dto";export interface MediaApi {uploadImage(formData: FormData): Promise<{ data: ImageDto }>;}
With the use of this interface we define a contract: the service says that it expects an object with the an uploadImage
function and the respective parameter and return value.
The important part: It doesn’t care about the implementation of this function. We can provide any object that matches this TypeScript interface.
And that comes in handy if we want to write a unit test.
I mentioned it before: with the original implementation of the media API we would have to mock the Axios client or use a mock server.
Now, with our new service that uses dependency injection, testing is pretty simple.
uploadImage
function providing a mock file as input parameter.// src/infrastructure/media/service.test.tsimport { createMockFile } from "@/test/utils";import { Blob } from "fetch-blob";import { describe, test, expect, vitest } from "vitest";import { MediaService } from "./service";// A simple implementation that returns the name of the// provided file in the URL. The return data matches the// structure or the server response.const mockMediaApi = {uploadImage: vitest.fn((formData: FormData) => {const file = formData.get("image") as File;return Promise.resolve({data: {id: "1",type: "image" as const,attributes: {url: `https://example.com/${file.name}`,},},});}),};describe("media repository", () => {test("uploads and returns an image", async () => {// Instantiate the service with the mock API and create// a mock file.const mediaService = new MediaService(mockMediaApi);const file = createMockFile("image.png");// Call the service method with the mock file.const image = await mediaService.saveImage(file);// Assert that the url contains the file name. This means// the data transformations inside the service are working.expect(image).toEqual({id: "1",url: `https://example.com/${file.name}`,});});});
You see, we didn’t need to mock any files. Instead we created a simple mock implementation of the MediaApi interface and pass it to the service. A nice little unit test enabled by dependency injection.
Note: I don’t think we necessarily need to test simple data transformations. But as soon as the service contains more logic like switching between API versions unit tests could be really valuable.
Since we wrapped the service function in a class we can’t directly export it in the barrel file anymore.
Instead we use another design pattern: a singleton.
A singleton is used whenever you want to expose a single instance to the rest of the application. This fits our situation well: We only need one instance of the MediaService
in our UI code.
Technically you’d use a private constructor and a getInstance()
function in the class. This way the class is responsible for creating the instance once and returning an existing instance if available.
In our case we don’t need this though. We can simply use our barrel file to create one instance and export it.
// src/infrastructure/media/index.tsimport mediaApi from "./api";import { MediaService } from "./service";const mediaService = new MediaService(mediaApi);export default mediaService;
Actually it’s not really important for us if there’s only one instance or if it’s created multiple times. This might be different if we, for example, established a websocket connection. But here this approach is sufficient.
Next we need to update our components. Actually there’s not much to do. The service’s barrel file still exports the same interface.
But you may have realized that I silently renamed the global /api
folder. We started out with a file src/api/media/api.ts
and a barrel file in the same folder. But the name api
provides a clue about the underlying data connection: a REST API.
This is an implementation detail from the perspective of the UI code.
Using a more generic name like infrastructure
instead decouples the UI from this knowledge. So the only change in the component now is to rename the import from import MediaApi from "@/api/media"
to import MediaService from "@/infrastructure/media"
.
// src/components/shout/reply-dialog.tsxAimport MediaService from "@/infrastructure/media";Aimport ShoutService from "@/infrastructure/shout";Aimport UserService from "@/infrastructure/user";...export function ReplyDialog({ children, shoutId }: ReplyDialogProps) {const [open, setOpen] = useState(false);const [isLoading, setIsLoading] = useState(true);const [isAuthenticated, setIsAuthenticated] = useState(false);const [hasError, setHasError] = useState(false);...async function handleSubmit(event: React.FormEvent<ReplyForm>) {event.preventDefault();setIsLoading(true);try {const message = event.currentTarget.elements.message.value;const files = event.currentTarget.elements.image.files;let image;if (files?.length) {Aimage = await MediaService.saveImage(files[0]);}Aconst newShout = await ShoutService.createShout({message,imageId: image?.id,});Aawait ShoutService.createReply({shoutId,replyId: newShout.id,});setOpen(false);} catch (error) {console.error(error);} finally {setIsLoading(false);}}// a form using the handleSubmit function is rendered herereturn (...);}
We’ve seen that creating services in the infrastructure layer can increase testability. But we also introduced more code. This is always a tradeoff.
In many situations you might not need this additional abstraction. For example:
Here’s one example from the project. A “service” that connects to authentication endpoints so the user can login and logout.
// src/infrastructure/auth/api.tsimport { apiClient } from "../client";import { Credentials } from "./dto";async function login(credentials: Credentials) {await apiClient.post("/login", credentials);}async function logout() {await apiClient.post("/logout");}export default { login, logout };
There’s really not much going on here. For consistency we could create a service with dependency injection as described above. But that would be merely an empty wrapper around the fetch functions.
Instead we can directly export the API client.
// src/infrastructure/auth/index.tsexport { default } from "./api";
The UI code doesn’t care if we exported a “real” service or directly these API functions. And in case that we’d have to add more logic that we wanted to unit test we could still create a service class and export it in the barrel file instead. As long as the interface stays the same the UI code doesn’t need to change.
For now, we’re done with the infrastructure layer. We created it as a global layer that uses services and dependency injection where unit tests are useful. But as always, there’s no silver bullet and depending on your project there might be different tradeoffs.
As I said, we’re done with the infrastructure layer for now. In the next article, we’ll focus on code closer to the UI components.
From my experience, in most React apps you can find different types of logic mixed together. Like in the form submit handler in this example:
In the next article, we’ll untangle this mix by introducing use-cases, a pattern known in Clean Architecture. This will separate concerns, isolate business logic from UI code, and make our code more testable.