Path To A Clean(er) React Architecture (Part 5) Infrastructure Services & Dependency Injection For Testability

Johannes KettmannPublished on 

The unopinionated nature of React is a two-edged sword:

  • On the one hand you get freedom of choice.
  • On the other hand many projects end up with a custom and often messy architecture.

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.

Problematic Code: API functions with too many responsibilities

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.ts
import { 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.tsx
import 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) {
image = 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 here
return (
...
);
}

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.

The problem: Mixed concerns and low testability

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.ts
async 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 solution: Services and dependency injection

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

  • separating the uploadImage function into a service and API client
  • wrapping it in a singleton class, and
  • using dependency injection for testability.

Let’s start with the first step.

Step 1: Separate service and API client

We start by going back to square one: We remove all logic from the uploadImage function.

// src/infrastructure/media/api.ts
import { 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.ts
import 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.

Step 2: Dependency injection and inversion of control

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.ts
import 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 };

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.ts
import { MediaApi } from "./interfaces";
import { dtoToImage } from "./transform";
export class MediaService {
constructor(private api: MediaApi) {
this.api = api;
}
async saveImage(file: File) {
const formData = new FormData();
formData.append("image", file);
const { 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.ts
import { 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.

Step 3: Unit testing the service

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.

  • First we create a mock media API and instantiate the service with it. This mock has to match the interface we defined in the last step. Otherwise, TypeScript shows an error.
  • Then we use it to create the service instance.
  • We then call the uploadImage function providing a mock file as input parameter.
  • Finally, we assert that the service returns the correct value based on the input type.
// src/infrastructure/media/service.test.ts
import { 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.

Step 4: Exposing a singleton instance to the UI layer

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.ts
import 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.

Step 5: Update the UI component

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.tsx
import MediaService from "@/infrastructure/media";
import ShoutService from "@/infrastructure/shout";
import 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) {
image = await MediaService.saveImage(files[0]);
}
const newShout = await ShoutService.createShout({
message,
imageId: image?.id,
});
await ShoutService.createReply({
shoutId,
replyId: newShout.id,
});
setOpen(false);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
}
// a form using the handleSubmit function is rendered here
return (
...
);
}

A service is not always necessary

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:

  • If your services only contain simple data transformation logic, you might not need unit tests for them. With TypeScript you can rely on the interfaces/types that your transformer accept and return. The actual implementation can then be tested with integration tests. Those should fail if you something breaks in the infrastructure layer.
  • If your API fetch functions don’t contain logic there’s no point in testing them. Again, integration tests should be sufficient in this scenario.

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.ts
import { 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.ts
export { 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.

The pros and cons of a global infrastructure layer

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.

Advantages

  • Our infrastructure layer isolates the UI code from data access. If the server API changes at some point we can we might be able to deal with it inside the infrastructure services. For example, if the user’s full name was returned as one field before but is now split into first and last name the service could return the combined name. Nothing in the UI would need to change. That doesn’t work always of course.
  • Logic inside the infrastructure services can easily be unit tested. That’s often much easier and faster than having to write integration tests for different scenarios inside data access logic.
  • We can reuse the services within the application. No need to duplicate data access logic in different parts of the code base.
  • The infrastructure layer is completely independent from the UI framework of our choice. We could migrate from React to the next shiny framework and keep all the code in this layer. We could move it to a separate npm package to make the code available to other applications or micro-frontends. Or we could move it to a backend-for-frontend service if needed.

Disadvantages

  • With this structure we created overhead and complexity. In small projects it’s easier to keep the fetch functions close to your components. It’s simpler to get an overview and to debug.
  • There’s a risk of introducing empty wrappers. As mentioned, if you value consistency you might e.g. create services for API functions that don’t contain any logic. You might want to be pragmatic and use these patterns only if they provide value.
  • This kind of architecture is not that common for React apps. At least from my experience. So any new developer has to be onboarded on the code base and its conventions. But to be honest, due to the unopinionated nature of React many projects have kind of custom architectures anyway.

Next refactoring steps

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.