Applying Domain-Driven Design (DDD) Principles in Frontend Development

Alae Achhab
3 min readNov 2, 2023

--

Starting with a Next.js Application: A Proposal for Separating API Calls, Business Logic, and React Components, Hooks, and Styles

I’m dividing the application into two major sections: ‘context’ and ‘modules.’

Context: This folder houses three subfolders for domain, application, and infrastructure for each context within the application.

Modules: It contains components, hooks, and stores specific to each context, which are used on different pages of the application.

my-app/
|-- src/
|-- context/
| |-- user/
| | |-- domain/
| | | |-- User.ts
| | | |-- UserRepository.ts
| | |-- application/
| | | |-- FindUserById.ts
| | |-- infrastructure/
| | |-- UserAPI.ts
| |-- product/
| | |-- domain/
| | | |-- Product.ts
| | | |-- ProductRepository.ts
| |-- application/
| | |-- FindAllProducts.ts
| |-- infrastructure/
| |-- ProductAPI.ts
|
|-- modules/
|-- user/
| |-- components/
| | |-- UserList.tsx
| | |-- UserForm.tsx
| |-- hooks/
| | |-- useUser.ts
| |-- stores/
| |-- userStore.ts
|
|-- product/
|-- components/
| |-- ProductList.tsx
| |-- ProductDetail.tsx
|-- hooks/
| |-- useProduct.ts
|-- stores/
|-- productStore.ts

In this example:

  • The “context” folder contains logic related to the domains of the application, the application layer, and the infrastructure for each context, such as “user” and “product.”
  • The “modules” folder houses context-specific components, hooks, and stores for each context, like “user” and “product.” These modules can be used on different pages of the application as needed.

The significant advantage of this organization is excellent maintainability, ease of testing, scalability, and complete decoupling of business logic from React and Next.js.

In this post, we will focus more on the ‘context’ folder, specifically the ‘user’ context, to explain how I organize the hexagonal architecture in this case.

Domain

// /src/context/user/domain/User.ts
import { z } from 'zod'

export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().min(1).email(),
})

export type User = z.infer<typeof UserSchema>

I use the Zod library to define the domain, which allows me to generate types and schemas. This, in turn, enables me to create mocks for testing with the @anatine/zod-mock library and simplifies validation tasks.

// /src/context/user/domain/UserRepository.ts
import { User } from './User'

export interface UserRepository {
findById: (id: string) => Promise<User>
}

In this example, I have the UserRepository, which only accepts the ‘findById’ method with a ‘string’ parameter and returns a ‘User’ promise, as defined in the previous file.

Application

// /src/context/user/application/FindUserById.ts
import { UserRepository } from '@context/user/domain/UserRepository'

export const FindUserById = (userRepository: UserRepository, id: string) => {
return userRepository.findById(id)
}

The ‘FindUserById’ service takes the ‘userRepository’ as its first parameter, which is of type ‘UserRepository.’ This approach is a form of dependency injection via parameters in the application layer. The second parameter, ‘id,’ is the value needed by the ‘findById’ method of the repository. This separation of concerns and the use of dependency injection allow for more flexibility and testability in the application.

Infrastructure

// /src/context/user/infrastructure/UserRepositoryApi.ts
import { AxiosError, isAxiosError } from 'axios'
import { ZodError } from 'zod'

import { User, UserSchema } from '@context/user/domain/User'

import { UserRepository } from '@context/user/domain/UserRepository'

import { ApiClient } from '@src/ApiClient'


const apiClient = ApiClient()

export const UserRepositoryApi = (): UserRepository => {
return {
findById,
}
}

async function findById(id: string) {
return await apiClient
.get<User>(`/users/${id}`)
.then(({ data }) => UserSchema.parse(data))
.catch((error: AxiosError | ZodError) => {
throw new Error(error)
})
}

In this code, you are defining your ‘UserRespositoryApi.ts’ as a ‘UserRepository’ with an implementation of the ‘findById’ method. This method takes an ‘id’ as a parameter and makes a GET request to the API. It expects a result of type ‘User,’ which is then validated using Zod. This means that the ‘UserRepositoryApi’ is responsible for interacting with the API to retrieve user data by ID and ensuring that the retrieved data adheres to the expected schema defined by ‘UserSchema’ using Zod for validation.

Conclusion

This is a straightforward and efficient approach to implementing a Domain-Driven Design (DDD) architecture in the frontend, emphasizing responsibility separation and minimizing coupling as much as possible. In the upcoming post, I’ll explain how I use context services from within the modules to provide dynamic data to React components.

--

--

Alae Achhab
Alae Achhab

Written by Alae Achhab

Experienced web dev with 10+ yrs. Backend to Frontend focus (React/Next.JS). Profound UX/UI understanding for successful web apps.

Responses (2)