Clean architecture in React app Clean architecture in React app

Clean architecture in React app

Simple implementation of a clean architecture

Introduction

This article is an attempt to consolidate my explorations of the clean architecture. It’s goal is to provide a hello-world implementation of the clean architecture. The example, used in this article is a simplified scenario I have in savings notebook app I’m currently working on. The example source code available at GitHub

Prerequisites

  • Basic knowledge of React, TypeScript and Classes

Summary of Clean Architecture

If you haven’t explored a topic of the Clean Architecture yet, look at the articles I attached at the references section. They describe it much better then I would, thus I’m going to focus on consolidation and application of clean architecture.

Clean Architecture is a type of Layered Architecture which is based on a variety of layered architectures such as Hexagonal or Onion. One of the key purpose of layered architectures is to separate a system into individual layers with a loose coupling between them. This separation leads to better maintainability of a system and ease of replacement of it’s parts. For example, if application is built with a layered architecture in mind, changing it’s database fom MongoDB to MySql should not require rewriting half of an application’s codebase.

To facilitate separation between the layers, the layered architecture enforces the dependency rule. Dependency rule restricts layers to depend on each other. And a common pattern to satisfy a dependency pattern is a dependency injection.

Layers

In Clean Architecture there are 4 layers:

  • Enterprise business rules Also referred to as a domain model. This is where the core entities are defined
  • Application business rules Sometimes seen as a part of a domain layer, which separated into model and application. In this layer the application use cases are defined
  • Adapters This layer serves the bridge between frameworks and application business rules and serve the main idea of separation of layers
  • Frameworks This layer is a representation of external services the app is using. For example database or ui library. Yes, react will be a part of framework layer.

Clean architecture diagram

The dependency rule manifest that the layers may depend only on inner layers. For example, application business rules must not import the modules, related to database management(frameworks layer)

application-layer/useCases.ts
import { Notebook } from "@domain";
import mongoose from "mongoose" // we must not import specific DB mechanism here, because it relates to an outer layer
/*
* UseCase to return Notebook
*/
class GetNotebook {
constructor(private notebookMongoose: mongoose) {}
async execute() {
await this.notebookMongoose.connect...
...
return new Notebook(name, creationDate);
}
}

Flow of control

In the clean architecture the common control flow of the app starts at the outer layer(usually GUI or CLI interface), and proceeds to another parts of outer layer(ie database) via inner layers.

Flow of control in clean architecture

Source: https://crosp.net/blog

Scenario description

My app is an android savings notepad build with react and tauri. Notebook is an entity, where user’s balances are stored. For the sake of simplicity in this example it will have only name and creationDate properties. Notebook's data is stored locally and accessed via tauri plugin-fs. It will be mocked in this example.

I’m going to implement a simplest scenario in which the name of a Notebook will be displayed on a page.

Project structure

The project were divided into clean architecture layers. index.ts is just an API, which re-exports its folder content

└── 📁src
└── 📁0-domain-model
├── index.ts
├── notebook.ts
└── 📁1-application
├── index.ts
├── ports.ts
├── useCases.ts
└── 📁2-adapters
├── index.ts
├── notebookController.ts
├── tauriNotebookRepository.ts
└── 📁3-frameworks
└── 📁services
├── index.ts
├── tauriFileReader.ts
└── 📁ui
├── App.tsx
├── index.css
├── main.tsx
├── composition.ts
└── vite-env.d.ts

Domain model

Domain model must not depend on any other layer, so in Notebook module there is no imports. In this example classes will be used, however clean architecture itself does not imply the necessity to use OOP. Objects or closures or other structures can be used to implement it as well.

domain-model/notebook.ts
class Notebook {
// this syntax automatically assigns name and creationDate to `this.name` and `this.creationDate`
constructor(
public name: string,
public creationDate: number,
) {}
}
export { Notebook };

Application layer

I’m going to start with creating the useCase, which needs to return the notebook entity:

application/useCases.ts
import { Notebook } from "@domain"; // Clean architecture allows to depend on inner layer
type GetNotebookInterface = {
execute: () => Promise<Notebook>;
};
class GetNotebook implements GetNotebookInterface {
async execute() {
console.log(
`[Application layer] GetNotebook is executing and creating instance of domain class...`,
);
// ...
return new Notebook(name, creationDate);
}
}

In order to work, this useCase requires to read the data of a notebook, which is stored in filesystem. I have a mock of specific tauri plugin which pretends to read the data from an android device. It requires fileUri and returns a string content of a file. It represents external module, provided by tauri framework, which I can’t change and gonna use as it is.

class TauriFileReader {
readFile(fileUri: string): Promise<string> {
console.log(
`[Framework layer] tauriFileReader reads file from ${fileUri}...`,
);
const fileData = "NotebookName,1751969945";
return new Promise((res) => setTimeout(() => res(fileData), 450));
}
}
export const tauriFileReader = new TauriFileReader();

The naive way to make GetNotebook use case work is to import tauriFileReader:

application/useCases.ts
import { Notebook } from "@domain";
import { tauriFileReader } from "@tauri"; // Clean architecture forbids importing from external layers
type GetNotebookInterface = {
execute: () => Promise<Notebook>;
};
class GetNotebook implements GetNotebookInterface {
constructor(private notebookReader: tauriFileReader) {}
async execute() {
console.log(
`[Application layer] GetNotebook is executing and creating instance of domain class...`,
);
const notebookData = await this.notebookReader// ...
// ...
return new Notebook(name, creationDate);
}
}

However, it will violate the Rule of dependencies, making application layer dependent on specific data retrieval mechanism. Imagine the situation: app grows and you need to add a new feature - cloud sync, so notebook will be retrieved not via tauriFileReader but from ModngoDb via mongoose. You will need to edit a use case and rewrite it’s logic:

import { tauriFileReader } from "@tauri";
import mongoose from "mongoose";

This is what layered architecture and a concept of loose coupling try to avoid. Ideally, inner layers of application should not change. It is implied that they are covered by tests and changing everything is very expensive and counter-productive.

Clean architecture way to make GetNotebook use case work with tauriFileReader is to specify the port, which is used by GetNotebook

Port is an entry or exit point of an application. This is an interface which is defined at the application layer which specify which external services (like tauriFileReader) the app requires to work.

However a port should not point to a concrete service (like tauriFileReader) and instead should point to an abstraction. This aligns with a dependency inversion principle

I’m going to specify the port as a TypeScript interface. To define the port I should rely on the GetNotebook needs, rather than on tauriFileReader implementation. To return Notebook instance, GetNotebook require name and creationDate - this is what I will specify as a desired output of abstract external component (this component is actually an adapter), the use case depends on.

application/ports.ts
/**
* Output port for service, which gets Notebook from somewhere.
*/
export type NotebookRepositoryPort = {
readNotebook: () => Promise<{ name: string; creationDate: number }>;
};
application/useCases.ts
import { Notebook } from "@domain";
import type { NotebookRepositoryPort } from "./ports"; // port is defined in the same layer, so the dependency rule is not violated
class GetNotebook {
constructor(private notebookRepository: NotebookRepositoryPort) {}
async execute() {
console.log(
`[Application layer] GetNotebook is executing and creating instance of domain class...`,
);
const { name, creationDate } = await this.notebookRepository.readNotebook();
return new Notebook(name, creationDate);
}
}

Adapters

Adapter layer connects application layer with external services (framework layer). Adapter depends on application port on the one side and concrete service on the other side.

tauriNotebookRepository must implement the interface NotebookRepositoryPort, specified in the application layer. This adapter requires external service TauriFileReader and this case is identical to previous one from the application layer: I can not import TauriFileReader directly from outer layer to meet the rule of dependencies. And I will just specify the interface of this service.

TauriFileReader requires uri which I mocked for simplicity. I specified uri only inside the adapter, because it is related to specific data retrieval mechanism and inner layer should not depend on it.

// adapters/tauriNotebookRepository
import type { NotebookRepositoryPort } from "@application"; // imports from inner layers allowed
/**
* Mocked uri
* In a real world scenario we would get it from somewhere, for example from
* user's config in localStorage.
*
* It's important to not pass this uri to methods related to inner layers.
* For example we should not pass this uri from a UI form directly to the `useCase`
* because it will make `useCase` depend on external `tauriFileReader`.
* Imagine if we gonna replace this specific local file reader mechanism with
* mongoDb, which will not need uri, but will need another parameters to work.
*/
const fileUri = "our/file/uri";
type FileReaderInterface = {
readFile(uri: string): Promise<string>;
};
/**
* Secondary (driven) adapter which access Notebook content via
* specific mechanism `tauriFileReader`
*/
class TauriNotebookRepository implements NotebookRepositoryPort {
constructor(
private fileReader: FileReaderInterface,
private uri = fileUri,
) {}
async readNotebook() {
console.log(
`[Adapters layer] TauriNotebookRepository is executing readNotebook()...`,
);
const notebookData = await this.fileReader.readFile(this.uri);
// adapter performs some logic to manipulate external service output
// and outputs result in a format, required by application's port
// (NotebookRepositoryPort)
const [name, creationDate] = notebookData.split(",");
return {
name,
creationDate: Number(creationDate),
};
}
}
export { TauriNotebookRepository };

Controller

So far I defined:

  • Notebook entity in the model layer
  • getNotebook use case and its NotebookRepositoryPort in application layer
  • TauriNotebookRepository adapter in adapters layer
  • tauriFileReader as an external service in framework layer

The application flow of control starts at application layer, and ends at tauriFileReader

illustration of control flow, where arrows go from application layer to adapter and then to framework layer,
explaining current flow of control

The remaining part is to connect UI with a getNotebook use case. Use case will be invoked from ui with a standard useEffect:

framework/ui/App.tsx
import { useEffect, useState } from "react";
// import { getNotebook } ??
function App() {
const [name, setName] = useState<string | null>(null);
useEffect(() => {
async function getNotebookName() {
console.log("[Framework layer] UI event calls the controller");
const notebookName = await getNotebook.execute().name;
setName(notebookName);
}
try {
getNotebookName();
} catch {
console.error("Error happened while getting the name");
}
});
if (!name) return <p>Loading...</p>;
return <p>The notebook's name is {name}</p>;
}
export default App;

However it is common not to call use case directly, but use a controller, which is a part of adapters layer. And just like with NotebookRepository adapter, it is required to specify a port for it in application layer.

application/ports.ts
/**
* Input port for controller, which requests Notebook data
*/
export type NotebookControllerPort = {
getNotebookName: () => Promise<string>;
};

notebookController must satisfy newly defined port and requires getNotebook use case to run. To loosen the coupling, getNotebook interface must be used instead of useCase itself.

adapters/notebookController.ts
import type {
GetNotebookInterface,
NotebookControllerPort,
} from "@application"; // it is ok to import from inner layer
/**
* Primary (driving) adapter. Executes specific useCase (can be multiple usecases)
* In this example it formats output in some way.
*/
export class NotebookController implements NotebookControllerPort {
constructor(private getNotebookUseCase: GetNotebookInterface) {}
/**
* @returns notebook name in upper case
*/
async getNotebookName() {
console.log(
`[Adapters layer] NotebookController is executing getNotebookName()...`,
);
const notebook = await this.getNotebookUseCase.execute();
return notebook.name.toUpperCase();
}
}

Finally, I can import notebookController, but again, I must follow dependency inversion principle and avoid dependency on a concrete implementation of notebookController and depend on interface. I created separate component which takes controller with NotebookControllerPort interface in props. In real world scenario it might be better ways to pass this dependency, but i’m trying to implement canonical clean architecture in the most simple way.

framework/ui/NotebookCard.tsx
import type { NotebookControllerPort } from "@application"; // import from inner layer is fine
import { useEffect, useState } from "react";
export function NotebookCard ({notebookController}: {notebookController: NotebookControllerPort}) {
const [name, setName] = useState<string | null>(null);
useEffect(() => {
async function getNotebookName() {
console.log("[Framework layer] UI event calls the controller");
const notebookName = await notebookController.getNotebookName();
setName(notebookName);
}
try {
getNotebookName();
} catch {
console.error("Error happened while getting the name");
}
});
if (!name) return <p>Loading...</p>;
return <p>The notebook's name is {name}</p>;
}

Now, flow of an application starts at the UI in framework layer, flow through inner layers and ends at the fileReader in framework layer.

    
---
title: flow of control
---
flowchart LR
  App.tsx --> notebookController
  notebookController --> getNotebook
  getNotebook --> TauriNotebookRepository
  TauriNotebookRepository --> tauriFileReader

  style App.tsx fill:blue
  style getNotebook fill:red
  style TauriNotebookRepository fill:green
  style tauriFileReader fill:blue
  style notebookController fill:green

  

Bringing all together

I ended up with a collection of loosely coupled modules. But the application will not work because defined modules requires concrete implementations of their dependencies, which I did not pass to them. To bring all the pieces together, I need use composition root. It is a place often at the entry point of an app, where all the actual dependencies is injected in their consumers. Composition root does not conceptually relates to any of architecture layers mentioned before

composition.tsx
import { NotebookController, TauriNotebookRepository } from "@adapters"; // TS module "/Users/philipp/Documents/GitHub/clean-architecture-feature/src/2-adapters/index"
import { GetNotebook } from "@application";
import { tauriFileReader } from "@frameworks/services";
import { NotebookCard } from "@frameworks/ui/NotebookCard";
/*
This is an example of manual dependency injection.
Automatic dependency injection techniques, like dependency injection container
might be used.
For js/ts there are inversifyJs and ts-loader libraries for automatic
dependency injection
*/
const notebookRepository = new TauriNotebookRepository(tauriFileReader);
const getNotebook = new GetNotebook(notebookRepository);
const notebookController = new NotebookController(getNotebook);
export function NotebookContainer() {
return <NotebookCard notebookController={notebookController} />;
}

To complete this example, I import NotebookContainer with all injected dependencies in App:

framework/ui/App.tsx
import { NotebookContainer } from "../../composition";
function App() {
return <NotebookContainer />;
}
export default App;

The result

App working as expected, returning:

The notebook’s name is NOTEBOOKNAME

And console illustrates the expected flow of control:

[Framework layer] UI event calls the controller...
[Adapters layer] NotebookController is executing getNotebookName()...
[Application layer] GetNotebook is executing and creating instance of domain class...
[Adapters layer] TauriNotebookRepository is executing readNotebook()...
[Framework layer] tauriFileReader reads file from our/file/uri...

In current implementation, the parts of a system can be easily replaced without the need of rewriting the other parts. For example to replace tauriFileReader with mongoDbAPI it only required to create new MongoNotebookRepository adapter and inject it in a composition root. Application layer, domain layer and other parts of adapters layer will not need any changes.

References

Happy coding!


← Back to home