
Как применить чистую архитектуру в приложении React
25 июля 2025 г.Введение
Эта статья является попыткой объединить мои исследованияЧистая архитектураПолем Его цель-обеспечить реализацию чистой архитектуры Hello-World. Пример, используемый в этой статье, представляет собой упрощенный сценарий, который у меня есть вСберегательная ноутбука приложениеЯ сейчас работаю. Пример исходного кода, доступный вGitHub
Предварительные условия
- Основные знания React, TypeScript и классов
Резюме чистой архитектуры
Если вы еще не изучили тему чистой архитектуры, посмотрите на статьи, которые я приложил в разделе ссылок. Они описывают это намного лучше, чем я, поэтому я собираюсь сосредоточиться на консолидации и применении чистой архитектуры.
Чистая архитектураэто типМногослойная архитектураЭто основано на различных слоистых архитектурах, таких как гексагональный или лук. Одной из ключевых целей слоистых архитектур является разделение системы на отдельные слои с свободной связью между ними. Это разделение приводит к улучшению обслуживания системы и простоте замены ее частей.
Например, если приложение построено с учетом слоистой архитектуры, изменение его базы данных с MongoDB на MySQL не должно потребовать переписывания половины кодовой базы приложения.
Чтобы облегчить разделение между слоями, многослойная архитектура обеспечивает соблюдениеПравило зависимостиПолем Правило зависимости ограничивает слои в зависимости от друг друга. И общий шаблон для удовлетворения схемы зависимости - этоИнъекция зависимостиПолем
Слои
В чистой архитектуре есть 4 слоя:
- Правила предприятия- также называетсядоменная модельПолем Здесь определяются основные объекты.
- Приложение бизнес -правила- иногда рассматривается как частьдоменслой, который разделен на модель и применение. На этом уровне определены варианты использования приложений.
- Адаптеры- Этот слой служит мосту между рамками и прикладными бизнес -правилами и служит основной идее разделения слоев.
- Рамки- Этот слой является представлением внешних служб, которые использует приложение. Например, база данных или библиотека пользовательского интерфейса. Да, ReAct будет частью структуры слоя.
Правило зависимости проявляется, что слои могут зависеть только от внутренних слоев. Например, прикладные бизнес -правила не должны импортировать модули, связанные с управлением базой данных (слой 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) и переходит к другим частям внешнего слоя (то есть базы данных) через внутренние слои.
Источник: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
Остальная часть - подключить пользовательский интерфейс с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 в рамочном слое.
Собрать все вместе
В итоге я получил коллекцию свободно связанных модулей. Но приложение не будет работать, потому что определенные модули требуют конкретных реализаций их зависимостей, которые я не передал им. Чтобы объединить все части, мне нужно использовать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, ... как я все это собрал вместе
- Архитектура портов и адаптеров
Счастливого кодирования!
Оригинал