

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.
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)
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.
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.
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:
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
:
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.
/** * Output port for service, which gets Notebook from somewhere. */export type NotebookRepositoryPort = { readNotebook: () => Promise<{ name: string; creationDate: number }>;};
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/tauriNotebookRepositoryimport 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 layergetNotebook
use case and itsNotebookRepositoryPort
in application layerTauriNotebookRepository
adapter in adapters layertauriFileReader
as an external service in framework layer
The application flow of control starts at application layer, and ends at tauriFileReader
The remaining part is to connect UI with a getNotebook
use case. Use case will be invoked
from ui with a standard useEffect
:
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.
/** * 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.
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.
import type { NotebookControllerPort } from "@application"; // import from inner layer is fineimport { 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
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 containermight be used.
For js/ts there are inversifyJs and ts-loader libraries for automaticdependency 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
:
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
- The Clean Architecture
- DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together
- Ports & adapters architecture
Happy coding!
← Back to home