Как применить чистую архитектуру в приложении React

Как применить чистую архитектуру в приложении React

25 июля 2025 г.

Введение

Эта статья является попыткой объединить мои исследованияЧистая архитектураПолем Его цель-обеспечить реализацию чистой архитектуры Hello-World. Пример, используемый в этой статье, представляет собой упрощенный сценарий, который у меня есть вСберегательная ноутбука приложениеЯ сейчас работаю. Пример исходного кода, доступный вGitHub

Предварительные условия

  • Основные знания React, TypeScript и классов

Резюме чистой архитектуры

Если вы еще не изучили тему чистой архитектуры, посмотрите на статьи, которые я приложил в разделе ссылок. Они описывают это намного лучше, чем я, поэтому я собираюсь сосредоточиться на консолидации и применении чистой архитектуры.

Чистая архитектураэто типМногослойная архитектураЭто основано на различных слоистых архитектурах, таких как гексагональный или лук. Одной из ключевых целей слоистых архитектур является разделение системы на отдельные слои с свободной связью между ними. Это разделение приводит к улучшению обслуживания системы и простоте замены ее частей.

Например, если приложение построено с учетом слоистой архитектуры, изменение его базы данных с MongoDB на MySQL не должно потребовать переписывания половины кодовой базы приложения.

Чтобы облегчить разделение между слоями, многослойная архитектура обеспечивает соблюдениеПравило зависимостиПолем Правило зависимости ограничивает слои в зависимости от друг друга. И общий шаблон для удовлетворения схемы зависимости - этоИнъекция зависимостиПолем

Слои

В чистой архитектуре есть 4 слоя:

  • Правила предприятия- также называетсядоменная модельПолем Здесь определяются основные объекты.
  • Приложение бизнес -правила- иногда рассматривается как частьдоменслой, который разделен на модель и применение. На этом уровне определены варианты использования приложений.
  • Адаптеры- Этот слой служит мосту между рамками и прикладными бизнес -правилами и служит основной идее разделения слоев.
  • Рамки- Этот слой является представлением внешних служб, которые использует приложение. Например, база данных или библиотека пользовательского интерфейса. Да, ReAct будет частью структуры слоя.

Clean architecture diagram

Правило зависимости проявляется, что слои могут зависеть только от внутренних слоев. Например, прикладные бизнес -правила не должны импортировать модули, связанные с управлением базой данных (слой Frameworks).

// 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);
	}
}

Поток управления

В чистой архитектуре общийуправление потокомприложения начинается с внешнего уровня (обычно графический интерфейс или CLI) и переходит к другим частям внешнего слоя (то есть базы данных) через внутренние слои.

Flow of control in clean architecture

Источник:https://crosp.net/blog

Сценарий Описание

Мое приложение - блокнот для сбережений Android, построенный с React и Tauri.Notebookэто сущность, где хранится остатки пользователей. Ради простоты, в этом примере он будет толькоnameиcreationDateхарактеристики.Notebook'sДанные хранятся локально и доступны через Тауриplugin-fsПолем Это будет издеваться в этом примере.

Я собираюсь реализовать простой сценарий, в котором название ноутбука будет отображаться на странице.

Структура проекта

Проект был разделен на чистые архитектурные слои.index.tsэто просто API, который реэкспортирует содержимое папки.

└── 📁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/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 };

Приложение слой

Я собираюсь начать с создания USECASE, который должен вернуть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);
	}
}

Чтобы работать, этот использование необходимо прочитать данные ноутбука, которая хранится в файловой системе. У меня есть макет конкретного плагина Tauri, который притворяется, что читает данные с устройства Android. Это требует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();

Наивный способ сделать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);
	}
}

Однако это нарушитПравило зависимостей, создание прикладного уровня в зависимости от конкретного механизма поиска данных. Представьте себе ситуацию: приложение растет, и вам нужно добавить новую функцию - облачный синхронизатор, поэтому ноутбук будет извлечен не через TaurifileReader, а из Mongodb через Mongoose. Вам нужно будет отредактировать вариант использования и переписать его логику:

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

Это то, что пытаются избежать слоистой архитектуры и концепции свободной связи. В идеале, внутренние слои приложения не должны меняться. Подразумевается, что они покрыты тестами, и менять все очень дорого и контрпродуктивно.

Чистый архитектурный способ сделатьGetNotebookиспользовать вариант работы сtauriFileReaderэто указатьport, который используетсяGetNotebook

Portявляется точкой входа или выхода приложения. Это интерфейс, который определяется вуровень приложения,которые указывают, какие внешние услуги (напримерtauriFileReader) приложение требует работы.

Однакоportне должно указывать на конкретный сервис (напримерtauriFileReader) и вместо этого должен указывать на абстракцию. Это согласуется сПринцип инверсии зависимостиПолем

Я собираюсь указать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/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 portС одной стороны и бетонным обслуживанием с другой стороны.

tauriNotebookRepositoryдолжен реализовать интерфейсNotebookRepositoryPort, указано в приложении. Этот адаптер требует внешнего обслуживанияTauriFileReader, и этот случай идентичен предыдущему из приложения: я не могу импортироватьTauriFileReaderнепосредственно от внешнего уровня, чтобы соответствовать правилу зависимостей. И я просто укажу интерфейс этой службы.

TauriFileReaderтребуетuriкоторый я издевался за простоту. Я указал 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 };

Контроллер

Пока что я определил:

  • Notebookсущность в модельном слое
  • getNotebookвариант использования и егоNotebookRepositoryPortв слое приложения
  • TauriNotebookRepositoryадаптер в слое адаптеров
  • tauriFileReaderВ качестве внешней службы в слое каркаса

Поток применения управления начинается с прикладного уровня и заканчивается наtauriFileReader

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

Остальная часть - подключить пользовательский интерфейс с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;

Однако часто нельзя напрямую не вызовать, но используйтеcontroller, который является частью слоя адаптеров. И, как и в случае с адаптером NotebookRepository, необходимо указатьportдля этого в приложении.

// application/ports.ts

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

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();
	}
}

Наконец, я могу импортировать NotebookController, но опять же, я должен следовать принципу инверсии зависимости и избежать зависимости от конкретной реализации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>;
}

Теперь поток приложения начинается с пользовательского интерфейса в структуре, протекает через внутренние слои и заканчивается на FileReader в рамочном слое.

flow diagram

Собрать все вместе

В итоге я получил коллекцию свободно связанных модулей. Но приложение не будет работать, потому что определенные модули требуют конкретных реализаций их зависимостей, которые я не передал им. Чтобы объединить все части, мне нужно использовать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} />;
}

Чтобы завершить этот пример, я импортируюNotebookContainerсо всеми введенными зависимостями вApp:

// framework/ui/App.tsx

import { NotebookContainer } from "../../composition";

function App() {
	return <NotebookContainer />;
}

export default App;

Результат

Приложение работает, как и ожидалось, возвращая:

Ноутбук назвал ноутбук

И консоль иллюстрирует ожидаемый поток контроля:

[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...

В текущей реализации части системы могут быть легко заменены без необходимости переписать другие части. Например, чтобы заменитьtauriFileReaderсmongoDbAPI, это требует только создания новогоMongoNotebookRepositoryадаптер и введите его в корне композиции. Прикладной слой, доменное слой и другие части слоя адаптеров не будут нуждаться в каких -либо изменениях.

Ссылки

  • Чистая архитектура
  • DDD, шестиугольный, лук, чистый, CQRS, ... как я все это собрал вместе
  • Архитектура портов и адаптеров

Счастливого кодирования!


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE