Introduction This article is an attempt to consolidate my explorations of the clean architecture. Its 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 the savings notebook app I'm currently working on. The example source code available at GitHub clean architecture savings notebook app GitHub Prerequisites Basic knowledge of React, TypeScript, and Classes Basic knowledge of React, TypeScript, and Classes Summary of Clean Architecture If you haven't explored the topic of Clean Architecture yet, look at the articles I attached in the references section. They describe it much better than I would, so I'm going to focus on consolidation and the application of clean architecture. Clean Architecture is a type of Layered Architecture that is based on a variety of layered architectures, such as Hexagonal or Onion. One of the key purposes 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 its parts. Clean Architecture Layered Architecture For example, if an application is built with a layered architecture in mind, changing its database from 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 from depending on each other. And a common pattern to satisfy a dependency pattern is dependency injection. dependency rule 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, a database or ui library. Yes, React will be a part of the framework layer. Enterprise business rules - Also referred to as a domain model. This is where the core entities are defined. Enterprise business rules domain model 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. Application business rules domain Adapters - This layer serves the bridge between frameworks and application business rules and serve the main idea of separation of layers. Adapters Frameworks - This layer is a representation of external services the app is using. For example, a database or ui library. Yes, React will be a part of the framework layer. Frameworks The dependency rule manifests 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); } } // 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 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 (i.e., database) via inner layers. control flow Source: https://crosp.net/blog https://crosp.net/blog Scenario Description My app is an android savings notepad built with React and Tauri. Notebook is an entity where users' 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. Notebook name creationDate Notebook's plugin-fs I'm going to implement a simple scenario in which the name of a Notebook will be displayed on a page. Project Structure The project was divided into clean architecture layers. index.ts is just an API, which re-exports its folder content. index.ts โโโ ๐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 โโโ ๐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 the Notebook module, there are 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 }; // 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: notebook //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); } } //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 the string content of a file. It represents an external module, provided by the Tauri framework, which I can't change and am gonna use as it is. fileUri 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(); 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: GetNotebook 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); } } // 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 MongoDB via mongoose. You will need to edit a use case and rewrite it's logic: Rule of dependencies - import { tauriFileReader } from "@tauri"; + import mongoose from "mongoose"; - import { tauriFileReader } from "@tauri"; + import mongoose from "mongoose"; This is what layered architecture and the concept of loose coupling try to avoid. Ideally, inner layers of the application should not change. It is implied that they are covered by tests, and changing everything is very expensive and counterproductive. A clean architecture way to make GetNotebook use case work with tauriFileReader is to specify the port, which is used by GetNotebook GetNotebook tauriFileReader port GetNotebook Port is an entry or exit point of an application. This is an interface that is defined at the application layer, which specifies which external services (like tauriFileReader) the app requires to work. Port application layer, tauriFileReader 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. port tauriFileReader 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. port port GetNotebook tauriFileReader Notebook GetNotebook name creationDate adapter // application/ports.ts /** * Output port for service, which gets Notebook from somewhere. */ export type NotebookRepositoryPort = { readNotebook: () => Promise<{ name: string; creationDate: number }>; }; // 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); } } // 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 The adapter layer connects the application layer with external services (framework layer). The adapter depends on the application port on one side and concrete service on the other side. application port tauriNotebookRepository must implement the interface NotebookRepositoryPort, specified in the application layer. This adapter requires external service TauriFileReader , and this case is identical to the previous one from the application layer: I can not import TauriFileReader directly from the outer layer to meet the rule of dependencies. And I will just specify the interface of this service. tauriNotebookRepository NotebookRepositoryPort TauriFileReader TauriFileReader 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. TauriFileReader uri // 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 }; // 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 have defined: Notebook entity in the model layer getNotebook use case and its NotebookRepositoryPort in the application layer TauriNotebookRepository adapter in the adapters layer tauriFileReader as an external service in the framework layer Notebook entity in the model layer Notebook getNotebook use case and its NotebookRepositoryPort in the application layer getNotebook NotebookRepositoryPort TauriNotebookRepository adapter in the adapters layer TauriNotebookRepository tauriFileReader as an external service in the framework layer tauriFileReader The application flow of control starts at the application layer and ends at tauriFileReader tauriFileReader The remaining part is to connect the UI with a getNotebook use case. Use case will be invoked from ui with a standard useEffect: getNotebook 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; // 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 the application layer. controller port // application/ports.ts /** * Input port for controller, which requests Notebook data */ export type NotebookControllerPort = { getNotebookName: () => Promise<string>; }; // application/ports.ts /** * Input port for controller, which requests Notebook data */ export type NotebookControllerPort = { getNotebookName: () => Promise<string>; }; notebookController must satisfy the newly defined port and requires getNotebook use case to run. To loosen the coupling, getNotebook interface must be used instead of useCase itself. notebookController getNotebook getNotebook // 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(); } } // 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 the dependency inversion principle and avoid dependency on a concrete implementation of notebookController and depend on an interface. I created a separate component that takes a controller with a NotebookControllerPort interface in props. In a real-world scenario, there might be better ways to pass this dependency, but I'm trying to implement canonical clean architecture in the simplest way. notebookController NotebookControllerPort // 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>; } // 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, the flow of an application starts at the UI in the framework layer, flows through inner layers, and ends at the FileReader in the framework layer. Bringing All Together I ended up with a collection of loosely coupled modules. But the application will not work because the defined modules require concrete implementations of their dependencies, which I did not pass to them. To bring all the pieces together, I need to 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. The composition root does not conceptually relate to any of the architecture layers mentioned before. composition root // 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} />; } // 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: NotebookContainer App // framework/ui/App.tsx import { NotebookContainer } from "../../composition"; function App() { return <NotebookContainer />; } export default 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 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... [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 the current implementation, the parts of a system can be easily replaced without the need to rewrite the other parts. For example, to replace tauriFileReader with mongoDbAPI, it only requires creating a 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. tauriFileReader mongoDbAPI MongoNotebookRepository References The Clean Architecture DDD, Hexagonal, Onion, Clean, CQRS, โฆ How I put it all together Ports & adapters architecture The Clean Architecture The Clean Architecture DDD, Hexagonal, Onion, Clean, CQRS, โฆ How I put it all together DDD, Hexagonal, Onion, Clean, CQRS, โฆ How I put it all together Ports & adapters architecture Ports & adapters architecture Happy coding!