Path To A Clean(er) React ArchitectureDomain Entities & DTOs

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 fourth 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 and data transformations. This way we already removed a lot of implementation details related to API requests from the UI code.

But we’re not done yet.

In this article, we prevent raw response data structures from entering UI code by introducing a domain layer and DTOs. Another step towards a cleaner architecture.

Bad Code: Data Structures Of API Responses In UI Code

Let’s have a look at a problematic code example. Here is a user profile component that fetches data from an API and renders it.

// src/pages/user-profile.tsx
import UserApi from "@/api/user";
import { User } from "@/types";
interface UserProfileProps {
handle: string;
}
export function UserProfile({ handle }: UserProfileProps) {
const [user, setUser] = useState<User>();
useEffect(() => {
UserApi.getUser(handle)
.then((user) => setUser(user));
}, [handle]);
return (
<section>
<img
src={user.attributes.avatar}
alt={`${user.attributes.handle}'s avatar`}
/>
<h1>@{user.attributes.handle}</h1>
<p>({user.relationships.followerIds.length} follower)</p>
<p>{user.attributes.info || "Shush! I'm a ghost."}</p>
</section>
);
}

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.

What is the problem?

The UI code uses an unnecessarily nested data structure for the user object. We have

  • user.attributes.xyz or
  • user.relationships.followerIds

multiple times. Here’s the TypeScript interface:

// src/types/index.ts
export interface User {
id: string;
type: "user";
attributes: {
handle: string;
avatar: string;
info?: string;
};
relationships: {
followerIds: string[];
};
}

Similar data structures can be found all over the project even inside components deep inside the component tree.

But what’s the reason for this nesting?

We use data structures defined by the API

Turns out the user data is directly returned from the fetch function we created in an earlier article:

// src/api/user.ts
import { User } from "@/types";
import { apiClient } from "./client";
async function getUser(handle: string) {
const response = await apiClient.get<{ data: User }>(`/user/${handle}`);
const user = response.data.data;
return user;
}
export default { getUser };

And inspecting the network requests we can see that this data structure is defined by the server.

The server seems to be using the popular JSON:API standard which is a great way to build APIs. But should we really use these data structures in the frontend?

Tight coupling of UI code to server API

The problem is: By passing the data coming from the API directly to the components we tightly couple the UI to the server. The response data doesn’t reflect the data structure of the (user) entity but is merely a data-transfer object (DTO).

DTO is a useful pattern as you can model the data according to the needs of the consumer. You can include additional data that isn’t included in the original database model like relationships.followerIds.

But exposing the DTO to the UI components leads to unnecessary complexity by accessing fields via user.attributes.xyz or object destructuring that bloats the code base like

const {
handle,
avatar,
} = user.attributes;
const {
followerIds
} = user.relationships;

Apart from the immediate extra complexity we also make the complete UI code dependent on changes of the server API.

Imagine the backend folks decide to ditch the JSON:API standard. Suddenly there are no user.attributes anymore. But we sprinkled them all over the UI code. So just because the server API changed we now have to adjust like 90% of the frontend code as well.

Solution: Introducing domain entities and DTOs

The domain model

We start by creating a new TypeScript interface that simplifies the User . In this case we just flatten the API data structure.

// src/domain/index.ts
export interface User {
id: string;
handle: string;
avatar: string;
info?: string;
followerIds: string[];
}

Side note: Here we introduced a new folder on the root level called domain__. The domain is a concept reflecting the core of the business used e.g. in the Clean Architecture or Domain Driven Design (DDD).

Instead of the data structures returned by the API let’s use the domain model in our component.

// src/pages/user-profile/user-info.tsx
import UserApi from "@/api/user";
import { User } from "@/domain";
interface UserProfileProps {
handle: string;
}
export function UserProfile({ handle }: UserProfileProps) {
const [user, setUser] = useState<User>();
useEffect(() => {
UserApi.getUser(handle)
.then((user) => setUser(user));
}, [handle]);
return (
<section>
<img
src={user.avatar}
alt={`${user.handle}'s avatar`}
/>
<h1>@{user.handle}</h1>
<p>({user.followerIds.length} follower)</p>
<p>{user.info || "Shush! I'm a ghost."}</p>
</section>
);
}

Great, we got rid of the annoying user.attributes and user.relationships simplifying our code a bit.

But more importantly, we decoupled our UI code from the server making it more resilient against external changes.

Great improvement, but we’re not done yet. UserApi.getUser(handle) still returns the nested data structure coming from the API.

DTOs and data transformations

The current fetch function in our API layer simply returns the API data.

// src/api/user.ts
import { User } from "@/types";
import { apiClient } from "./client";
async function getUser(handle: string) {
const response = await apiClient.get<{ data: User }>(`/user/${handle}`);
const user = response.data.data;
return user;
}
export default { getUser };

With our components expecting the domain model we need some changes here as well.

We start by defining the model of the data transfer object (DTO) in the API layer.

// src/api/user/dto.ts
export interface UserDto {
id: string;
type: "user";
attributes: {
handle: string;
avatar: string;
info?: string;
};
relationships: {
followerIds: string[];
};
}

From now on this data structure is only used inside the API layer and is not to be exposed to the rest of the application code.

Thus we need to transform the API response data to the domain model. A simple function will do the job.

// src/api/user/transform.ts
import { User } from "@/domain";
import { UserDto } from "./dto";
export function dtoToUser(dto: UserDto): User {
return {
id: dto.id,
avatar: dto.attributes.avatar,
handle: dto.attributes.handle,
info: dto.attributes.info,
followerIds: dto.relationships.followerIds,
};
}

Next, we apply this function to the response data before returning it to the component.

// src/api/user/api.ts
import { apiClient } from "../client";
import { UserDto } from "./dto";
import { dtoToUser } from "./transform";
async function getUser(handle: string) {
const response = await apiClient.get<{ data: UserDto }>(`/user/${handle}`);
const userDto = response.data.data;
return dtoToUser(userDto);
}
export default { getUser };

The good and the bad

Pros: Cleaner architecture, more resilience, better separation

From an architectural perspective the new code is a lot cleaner:

  • The data structures coming from the API are only used in the API layer as DTOs.
  • The UI code is effectively isolated from the server’s REST API.
  • Most of the application code uses shared domain models.

This way the code is more resilient and concerns are separated.

Cons: More code, more complexity

But this decoupling also comes with a cost: Boilerplate.

For each entity we need to add one domain model, at least one DTO, and one transformer function. We have more code and a lot more files in our code base.

Also the level of complexity increased. At least for developers who aren’t familiar with the code base or its architecture.

Conventions and automation

Once you get used to the architecture everything is quite clear though thanks to the conventions and consistency.

  • The transformer files contain data transformation functions.
  • The API files handle the requests.
  • The domain layer contains the application wide data structures.

And while the boilerplate problem exists you can also automate the creation of some of these files depending on your tech stack.

For example:

  • For fullstack TypeScript applications you can use shared domain models on the server and the client e.g. in a monorepo or by moving them in a separate npm package.
  • If you have a REST API that is well-documented with OpenAPI you can automatically generate your domain models or DTOs.
  • Depending on the data structures in the API responses you might not need DTOs and data transformations. You can always introduce them later if the REST API changes.

Next refactoring steps

You may have realized that we’re slowly adding responsibilities to the API fetch functions, like getUser. It’s not only responsible for sending API requests but also for transforming response data.

// src/api/user/api.ts
async function getUser(handle: string) {
const response = await apiClient.get<{ data: UserDto }>(`/user/${handle}`);
const userDto = response.data.data;
return dtoToUser(userDto);
}
export default { getUser };

Here’s another example from the code base:

In the next article of this series we will use the repository pattern to move some of these responsibilities away from the fetch functions.

Improve your health as a software developer
After years working in front of a screen, my health was at an all-time low. I gained weight, my back hurt, and I was constantly tired. I knew I had to make a change. That's why I started Office Walker. I want to help you improve your health and productivity as a software developer.