Path To A Clean(er) React Architecture (Part 7)Domain Logic

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 seventh 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,

This helped us in isolating our UI code from the server, make business logic independent of the UI framework, and increase testability.

But we’re not done yet.

In this article, we’ll focus on the core of our application: the domain.

Domain logic is code that operates on the domain models like a user object. That may sound abstract but we’ll see what it means in some hands-on examples. The goal is to isolate this logic from our components, move it to a specific place in the repository, and unit test it.

If you’ve ever wondered where you can place certain parts of logic if not a utility file or a custom hook this might be an interesting read.

Problematic code example: Domain logic inside a component

Let’s have a look at a problematic code example. Here’s a component that renders a list of Shouts (aka Tweets or Posts).

You can find the source code before and after this refactoring. Addtionally you can find an overview of all the changes for this article here.

// src/components/shout-list/shout-list.tsx
import { Shout } from "@/components/shout";
import { Image } from "@/domain/media";
import { Shout as IShout } from "@/domain/shout";
import { User } from "@/domain/user";
interface ShoutListProps {
shouts: IShout[];
images: Image[];
users: User[];
}
export function ShoutList({ shouts, users, images }: ShoutListProps) {
return (
<ul className="flex flex-col gap-4 items-center">
{shouts.map((shout) => {
const author = users.find((u) => u.id === shout.authorId);
const image = shout.imageId
? images.find((i) => i.id === shout.imageId)
: undefined;
return (
<li key={shout.id} className="max-w-sm w-full">
<Shout shout={shout} author={author} image={image} />
</li>
);
})}
</ul>
);
}
  • The component receives a list of shouts together with a list of users and images that belong to these shouts.
  • It iterates over the shouts.
  • For each of the shouts it gets the corresponding author and the image.
  • Finally it returns a list item element for each shout.

The problem: Mixed concerns and low testability

The problematic lines of this component find the author and image of a Shout.

const author = users.find((u) => u.id === shout.authorId);
const image = shout.imageId
? images.find((i) => i.id === shout.imageId)
: undefined;

These are operations on the user and image entities. Namely this code defines how the user and image lookup work in our application.

Additionally there’s a possibility that a Shout doesn’t have an image. Because of that we need a ternary expression. From my perspective that’s not the most beautiful or readable code.

The solution: Extracting logic to the domain layer

In an earlier article, we created a domain layer holding the core models of our application like User or Image as TypeScript interfaces. This turns out to be a great place to put logic that operates on domain entities.

Let me show you…

Step 1: Creating functions in the domain layer

Taking the example code above we can create a new domain function called getUserById:

// src/domain/user/user.ts
export interface User {
id: string;
handle: string;
avatar: string;
info?: string;
blockedUserIds: string[];
followerIds: string[];
}
export function getUserById(users?: User[], userId?: string) {
if (!userId || !users) return;
return users.find((u) => u.id === userId);
}

In this example, we make the function more flexible than required by making both parameters optional. From my experience, this is especially useful when you work with asynchronous data or with libraries like react-query.

We can do the same for the image:

// src/domain/media/media.ts
export interface Image {
id: string;
url: string;
}
export function getImageById(images?: Image[], imageId?: string) {
if (!imageId || !images) return;
return images.find((i) => i.id === imageId);
}

Step 2: Updating the component

Now in the component the advantages become evident:

// src/components/shout-list/shout-list.tsx
import { Shout } from "@/components/shout";
import { Image, getImageById } from "@/domain/media";
import { Shout as IShout } from "@/domain/shout";
import { User, getUserById } from "@/domain/user";
...
export function ShoutList({ shouts, users, images }: ShoutListProps) {
return (
<ul className="flex flex-col gap-4 items-center">
{shouts.map((shout) => (
<li key={shout.id} className="max-w-sm w-full">
<Shout
shout={shout}
author={getUserById(users, shout.authorId)}
image={getImageById(images, shout.imageId)}
/>
</li>
))}
</ul>
);
}
  1. The code is more readable because the reader’s brain doesn’t have to translate the users.find(...) functions in to ID lookups.
  2. We got rid of the ternary expression further improving readability.
  3. The logic is now much simpler to test.

What do I mean by “simpler to test”?

Step 3: Unit testing domain logic

In the original code we’d have to test the logic by passing different versions of the shouts, users, and images props. Here a quick reminder of the original code:

export function ShoutList({ shouts, users, images }: ShoutListProps) {
return (
<ul className="flex flex-col gap-4 items-center">
{shouts.map((shout) => {
const author = users.find((u) => u.id === shout.authorId);
const image = shout.imageId
? images.find((i) => i.id === shout.imageId)
: undefined;
return (
<li key={shout.id} className="max-w-sm w-full">
<Shout shout={shout} author={author} image={image} />
</li>
);
})}
</ul>
);
}

Here specifically we’d have to provide different test cases for Shouts that contain an imageId and that don’t. We might even want to test other edge cases like a missing author or so. On top of that we’d have to test the component with e.g. React Testing Library which creates additional overhead.

Compared to that writing unit tests for our new domain functions is very straightforward:

// src/domain/media/media.test.ts
import { describe, expect, it } from "vitest";
import { getImageById } from "./media";
const mockImage = {
id: "1",
url: "test",
};
describe("Media domain", () => {
describe("getImageById", () => {
it("should be able to get image by id", () => {
const image = getImageById([mockImage], "1");
expect(image).toEqual(mockImage);
});
it("should return undefined if image is not found", () => {
const image = getImageById([{ ...mockImage, id: "2" }], "1");
expect(image).toEqual(undefined);
});
it("should return undefined if provided images are not defined", () => {
const image = getImageById(undefined, "1");
expect(image).toEqual(undefined);
});
it("should return undefined if provided image id is not defined", () => {
const image = getImageById([mockImage], undefined);
expect(image).toEqual(undefined);
});
});
});

We can test all the possible branches without any setup code as often required by React Testing Library and these tests are blazing fast.

This can provide the opportunity to remove some (more expensive) integration tests that would otherwise be required.

Another Problematic code example

Let me try to solidify this approach with another example. In a previous article, we have a hook that exposes a use-case function as a way to remove business logic from a component. This function

  • first runs a few lines of validation logic
  • followed by a series of service calls.
// src/application/reply-to-shout/reply-to-shout.ts
import { useCallback } from "react";
import MediaService from "@/infrastructure/media";
import ShoutService from "@/infrastructure/shout";
import UserService from "@/infrastructure/user";
...
export async function replyToShout(
{ recipientHandle, shoutId, message, files }: ReplyToShoutInput,
{ getMe, getUser, saveImage, createReply, createShout }: typeof dependencies
) {
const me = await getMe();
if (me.numShoutsPastDay >= 5) {
return { error: ErrorMessages.TooManyShouts };
}
const recipient = await getUser(recipientHandle);
if (!recipient) {
return { error: ErrorMessages.RecipientNotFound };
}
if (recipient.blockedUserIds.includes(me.id)) {
return { error: ErrorMessages.AuthorBlockedByRecipient };
}
try {
let image;
if (files?.length) {
image = await saveImage(files[0]);
}
const newShout = await createShout({
message,
imageId: image?.id,
});
await createReply({
shoutId,
replyId: newShout.id,
});
return { error: undefined };
} catch {
return { error: ErrorMessages.UnknownError };
}
}
export function useReplyToShout() {
return useCallback(
(input: ReplyToShoutInput) => replyToShout(input, dependencies),
[]
);
}

The problem: Mixture of domain and business logic

The problematic lines of code are in the data validation:

const me = await getMe();
if (me.numShoutsPastDay >= 5) {
return { error: ErrorMessages.TooManyShouts };
}
const recipient = await getUser(recipientHandle);
if (!recipient) {
return { error: ErrorMessages.RecipientNotFound };
}
if (recipient.blockedUserIds.includes(me.id)) {
return { error: ErrorMessages.AuthorBlockedByRecipient };
}

These are again operations on domain entities: User and Me (the current user).

The solution: Creating functions in the domain layer

And again we can move this logic to the domain layer:

// src/domain/me/me.ts
import { User } from "@/domain/user";
export const MAX_NUM_SHOUTS_PER_DAY = 5;
export interface Me extends User {
numShoutsPastDay: number;
}
export function hasExceededShoutLimit(me: Me) {
return me.numShoutsPastDay >= MAX_NUM_SHOUTS_PER_DAY;
}

Now the domain is responsible for deciding whether or not a user has exceeded the shout limit. This allows us to remove implementation details such as

  • the number of shouts allowed
  • the interval for this threshold (in this case one day)

from the code that’s closer to the UI.

We can do the same with the check of the blockedUserIds:

// src/domain/user/user.ts
export interface User {
id: string;
handle: string;
avatar: string;
info?: string;
blockedUserIds: string[];
followerIds: string[];
}
...
export function hasBlockedUser(user?: User, userId?: string) {
if (!user || !userId) return false;
return user.blockedUserIds.includes(userId);
}

The use-case function now reads simpler and as mentioned contains less implementation details related to the domain (e.g. how many times a user can shout in a given interval).

// src/application/reply-to-shout/reply-to-shout.ts
import { hasExceededShoutLimit } from "@/domain/me";
import { hasBlockedUser } from "@/domain/user";
export async function replyToShout(
{ recipientHandle, shoutId, message, files }: ReplyToShoutInput,
{ getMe, getUser, saveImage, createReply, createShout }: typeof dependencies
) {
const me = await getMe();
if (hasExceededShoutLimit(me)) {
return { error: ErrorMessages.TooManyShouts };
}
const recipient = await getUser(recipientHandle);
if (!recipient) {
return { error: ErrorMessages.RecipientNotFound };
}
if (hasBlockedUser(recipient, me.id)) {
return { error: ErrorMessages.AuthorBlockedByRecipient };
}

Finally, we again can test this logic with simple unit tests:

// src/domain/user/user.test.ts
import { describe, expect, it } from "vitest";
import { getUserById, hasBlockedUser } from "./user";
const mockUser = {
id: "1",
handle: "test",
avatar: "test",
numShoutsPastDay: 0,
blockedUserIds: [],
followerIds: [],
};
describe("User domain", () => {
describe("getUserById", () => { ... });
describe("hasBlockedUser", () => {
it("should be false if user has not blocked the user", () => {
const user = { ...mockUser, blockedUserIds: ["2"] };
const hasBlocked = hasBlockedUser(user, "3");
expect(hasBlocked).toEqual(false);
});
it("should be true if user has blocked the user", () => {
const user = { ...mockUser, blockedUserIds: ["2"] };
const hasBlocked = hasBlockedUser(user, "2");
expect(hasBlocked).toEqual(true);
});
it("should be false if user is not defined", () => {
const hasBlocked = hasBlockedUser(undefined, "2");
expect(hasBlocked).toEqual(false);
});
it("should be false if user id is not defined", () => {
const hasBlocked = hasBlockedUser(mockUser, undefined);
expect(hasBlocked).toEqual(false);
});
});
});

The pros and cons of extracting domain logic

Let’s quickly discuss some of the advantages and disadvantages of this approach.

Advantages

  • Less utility functions: Without the domain layer it’s often unclear where you put logic like the above. From my experience it’s typically scattered around the components or you can find it in utility files. Utility files can become problematic though as they easily turn into a dumping ground for all kinds of shared code.
  • Readability: While it’s not a lot users.find(({ id }) => id === userId) requires a bit of cognitive overload to translate this into a ID lookup. Reading getUserById(users, userId) instead is much more descriptive. That’s especially effective if you many of these lines grouped together (e.g. at the top of a component).
  • Testability: You can often find code that uses if/switch statements or ternaries. Each of these mean that there are multiple test branches to be covered. It can be much easier to write unit tests for all the edge cases and reduce the number of integration tests to the ones being strictly necessary.
  • Reusability: Often these small pieces of logic might not seem worthy of being extracted into separate functions. Then they are repeated in the code indefinitely. But a small change of requirements can easily lead to a bigger refactoring.
  • Search-ability: It’s not that simple to run a global search on e.g. users.find(({ id }) => id === userId). The code might be written in different ways with different variable names. A global search for getUserById is straightforward though.

Disadvantages

  • Training: Not every developer is used to thinking in different kinds of logic. So documentation and training might be required if you want to keep your code base consistent.
  • Overhead: As you’ve seen in one of the examples above, we made some of the domain functions more flexible than required by the specific component (here we made the users and userId parameters of the getUserById function optional). Since we also covered these cases with unit tests we introduced more code than required leading to more maintenance effort. At the same time, this can save your butt if you e.g. suddenly encounter unexpected response data from the server.

Next refactoring steps

Until now we didn’t introduce any other tool than React itself. From my perspective, this was important as it can be difficult to transfer knowledge from one tech stack to another if things are mingled together.

But now it’s time to confront the reality of production React apps.

One of the most common tools used in the wild is react-query (or another server state management library like RTK query or SWR). How do these fit into our architecture? That’s the topic of the next article.