Architecturing React Applications with Domain-Driven Design - it becomes essential to devise a sustainable architecture that makes it easier to manage
Datably, Inc
Helping you grow your business faster with an extreme focus on client satisfaction and customized software development!
By: Bryant Wooters, Software Engineer at Datably, Inc.
When developing applications of significant complexity, it becomes essential to devise a sustainable architecture that makes it easier to manage the complexities. React, despite being a robust library for building user interfaces, needs to provide explicit guidelines on how to structure larger applications. Here, Domain-Driven Design (DDD) provides a full set of practices that can assist us in creating complex applications while ensuring scalability and maintainability. This article delves into how we can use DDD in architecturing React applications.
What is Domain-Driven Design (DDD)?
Domain-Driven Design (DDD) is an approach to software development that emphasizes collaboration between technical and domain experts. It focuses on understanding the problem domain to accurately model the software, making it align better with business requirements.
DDD's primary aim is to create a model of the business domain that encapsulates both behavior and data. This model is then used to design and drive the software architecture. Some key concepts in DDD include entities, value objects, aggregates, services, repositories, and factories.
Why Use DDD with React?
React provides great flexibility in how you structure your projects, but this flexibility can sometimes lead to architectural challenges as your application grows. DDD offers a strategic design to tame the complexity by aligning software design with business objectives.
By using DDD principles, we can design our React components around the domain model, resulting in a clear separation of concerns, better code organization, and easier maintainability. Additionally, DDD encourages encapsulating business logic in the domain layer, resulting in cleaner, more testable components.
How to Apply DDD in React Applications?
1. Define Your Bounded Contexts
The first step in applying DDD is to define your Bounded Contexts clearly. Bounded Context provides the boundaries or the 'context' in which a particular model is valid and works as expected. For instance, in an e-commerce application, "Product Catalog", "Order Management", and "Customer Service" could each be a Bounded Context.
2. Model Your Domain
In this step, you create a model that reflects the business domain. This model could comprise various domain elements like Entities, Value Objects, Aggregates, or Domain Events. In the context of a React application, this can be done using TypeScript classes. Consider the following Task class. This class is an example of a domain model in DDD. The Task class represents a task entity in the business domain, encapsulating both the data and the behaviors associated with a task.
export class Task {
????public id: number;
????public title: string;
????public description: string;
????constructor(id: number, title: string, description: string) {
????????this.id = id;
????????this.title = title;
????????this.description = description;
????}
????public EmptyFields() {
????????return new Task(0, "", "");
????}
????public SetTitle(title: string) {
????????this.title = title;
????????return new Task(this.id, this.title, this.description);
????}
????public SetDescription(description: string) {
????????this.description = description;
????????return new Task(this.id, this.title, this.description);
????}
}
In this class, we have methods like SetTitle and SetDescription, which allow us to modify the state of the Task object in a controlled manner. You may wonder why we don't use the built-in set keyword for these methods. Later on, you will see that to use this class directly in state; additional work is required in each method, that being the fact that each time we operate, we return a new object with the updated properties. This will be important to note when we use this class with state. This class is an instance of a well-defined domain model, which holds the data and the behaviors that can be performed on the data.
By designing our React components around such domain models, we ensure a clear separation of concerns, better code organization, and easier maintainability. Additionally, the domain model encapsulates the business logic, resulting in cleaner, more testable components.
领英推荐
3. Design Your Application Layer
The Application Layer is where the use cases of your application reside. This layer is responsible for orchestrating your domain objects to perform business operations. In the context of a React application, the state and related logic are encapsulated within domain objects or value objects.
In a React application, this layer typically encompasses React components and hooks, which manage the interaction between the UI and the domain. Specifically, we use the useState hook to maintain the state of our domain objects. However, instead of directly implementing state updates within the setState callback, we encapsulate this logic within a class. Here's an example using TypeScript:
export const AddTask = ({dataTestId, taskRepo}: IAddTaskProps) => {
????const taskContext = useContext(TaskContext);
????const [task, setTask] = useState<Task>(new Task(0, "", ""));
????return (
????????<>
????????????<div className="add-task-background">
????????????????<h1 className="add-task-title" role="h1">Add Task</h1>
????????????????<div className="flex justify-between items-center"
?????????????????????data-testid={dataTestId}>
????????????????????<div className="flex flex-col space-y-5 w-96">
????????????????????????<Input
????????????????????????????dataTestId="input"
????????????????????????????placeholderText="Title"
????????????????????????????value={task.title}
????????????????????????????onChange={(e) => setTask(task => task.SetTitle(e.target.value))}
????????????????????????/>
????????????????????????<TextArea
????????????????????????????placeholderText="Description..."
????????????????????????????dataTestId="textArea"
????????????????????????????value={task.description}
????????????????????????????onChange={(e) => setTask(task => task.SetDescription(e.target.value))}
????????????????????????/>
????????????????????</div>
????????????????????<Button
????????????????????????onClick={() => {
????????????????????????????taskRepo.AddTask(task)
????????????????????????????????.then((id) => {
????????????????????????????????????return taskListContext?.setTasks((tasks) =>?
????????????????????????????????????{
????????????????????????????????????????try {
???????????????????????????????????????????return tasks?.AddTask(id, task);
????????????????????????????????????????} catch (e: any) {
???????????????????????????????????????????alert(e.message);
???????????????????????????????????????????console.log(e);
????????????????????????????????????????? ?return tasks;
????????????????????????????????????????}
????????????????????????????????????});
????????????????????????????????})
????????????????????????????????.catch(e => alert(e.message))
????????????????????????????setTask(task => task.EmptyFields());
????????????????????????}}
????????????????????????buttonText="Add Task"
????????????????????????dataTestId="button"
????????????????????/>
????????????????</div>
????????????</div>
????????</>
????)
}
In this example, we have an "AddTask" component. When the "Add Task" button is clicked, it ultimately sends a POST request to add the task to the backend. In a successful POST, it triggers setTasks to update the state. This state update happens via the tasks. AddTask(id, task) method, which is encapsulated within the Task class, preserves the domain model's integrity.
This approach leads to a clear separation of concerns, with the React component handling UI interactions and the Task class managing the domain logic. As a result, the code becomes more predictable, maintainable, and consistent with DDD principles. Now if we take a look at our Task class, we can see why we didn't use the exact implementation of a traditional JavaScript setter method. Ultimately, React expects a new object to be created every time we update state. This approach encapsulates the complexity behind how state is updated, making it much more readable and testable.
export class Task {
????public id: number;
????public title: string;
????public description: string;????
????constructor(id: number, title: string, description: string) {
????????this.id = id;
????????this.title = title;
????????this.description = description;
????}
????
????public EmptyFields() {
????????return new Task(0, "", "");
????}??
????public SetTitle(title: string) {
????????this.title = title;
????????return new Task(this.id, this.title, this.description);
????}?
????public SetDescription(description: string) {
????????this.description = description;
????????return new Task(this.id, this.title, this.description);
????}
}
4. Implement the Infrastructure Layer
The Infrastructure Layer is responsible for providing generic technical capabilities to support higher layers. This can include things like API clients, databases, or local storage.
In a React application, this might include modules for fetching data from APIs, utility functions, or any third-party services. The example below has a Task Repository and a Task Gateway. We are using object composition to construct each of these classes. Using this kind of layered approach helps to adhere to the Single Responsibility Principle. In the Gateway, we are responsible for ensuring that the right endpoints are used along with calling the correct HTTP verbs. In the repository, we take our domain object that has been updated, and we convert it into the view model (or DTO) that the server expects to receive. This is a simple example, but sometimes we might need different items for a particular view state in a different format than the view model. This layer ensures the data gets translated into the correct format before going to the server.
export class TaskRepository implements ITaskRepository {
????private readonly _taskGateway: ITaskGateway;
????constructor(taskGateway: ITaskGateway) {
????????this._taskGateway = taskGateway;????????
????}
????public async GetAllTasks() {
????????const result = await this._taskGateway.GetAllTasks();
????????const tasks: Task[] = result.map(x => new Task(x.id, x.title, x.description));
????????return new TaskItemsView(tasks);
????}
????public async AddTask(task: Task): Promise<number> {
????????const vm = TaskToViewModelMapper.MapTaskToTaskCreateViewModel(task);
????????return await this._taskGateway.AddTask(vm);
????}??
????public async DeleteTask(taskId: number) {
????????return await this._taskGateway.DeleteTask(taskId);
????}
}
export class TaskGateway implements ITaskGateway {
????private readonly _apiGateway: IAPIGateway;
????private readonly _basePath: string = "api/task"
????constructor(apiGateway: IAPIGateway) {
????????this._apiGateway = apiGateway;
????};?
????public async GetAllTasks(): Promise<TaskListViewModel[]> {
????????return await this._apiGateway.Get(`${this._basePath}/list`);
????};?
????public async AddTask(taskCreateVm: TaskCreateViewModel): Promise<number> {
?? ?????return await this._apiGateway.Post(`${this._basePath}/`, taskCreateVm);
????}
????public async DeleteTask(taskId: number): Promise<void> {
????????return await this._apiGateway.Delete(`${this._basePath}/${taskId}`);
????}
}
????}
}
export class TaskRepository implements ITaskRepository {
????private readonly _taskGateway: ITaskGateway;
????constructor(taskGateway: ITaskGateway) {
????????this._taskGateway = taskGateway;????????
????}
????public async GetAllTasks() {
????????const result = await this._taskGateway.GetAllTasks();
????????const tasks: Task[] = result.map(x => new Task(x.id, x.title, x.description));
????????return new TaskItemsView(tasks);
????}
????public async AddTask(task: Task): Promise<number> {
????????const vm = TaskToViewModelMapper.MapTaskToTaskCreateViewModel(task);
????????return await this._taskGateway.AddTask(vm);
????}??
????public async DeleteTask(taskId: number) {
????????return await this._taskGateway.DeleteTask(taskId);
Conclusion
While DDD provides a robust set of practices, it's important to remember that it's not a silver bullet. DDD can add unnecessary complexity to a simple application with minimal business logic. However, for larger, more complex applications, incorporating DDD principles into your React architecture can significantly enhance the maintainability and scalability of your codebase. It promotes clear boundaries, encourages modeling around the domain, and ensures a clean separation of concerns, leading to a more sustainable and robust application structure.
Full Stack Engineer
8 个月Hi guys! Fantastic deep dive into DDD and clean architecture here! I've been building production apps using these concepts and I can attest it pays off. I've put together a simple React application that embodies the principles and strategies discussed here, showcasing various state management approaches (React state vs Redux) among other patterns, to illuminate the real-world flexibility and scalability these architectures afford. It's a really simple app but you can use it as a guideline for your projects! https://github.com/nicmesan2/todo-list-clean-architecture Of course, any feedback is more than welcome :) Cheers!
DevOps/Support Engineer ? JavaScript/TypeScript, Python, React, Node.js, MySQL, SQL Server, Microsoft Azure ? Automating and solving problems and tasks for everyone, everywhere
1 年Laying down the knowledge of all the development approaches!