Теоретическая часть
Перед тем как перейти к реализации, важно зафиксировать архитектурные принципы, на которых построен данный пример.
Почему разработка начинается с domain
Слой domain является центральным элементом архитектуры.
Именно здесь описываются:
- бизнес-сущности;
- бизнес-сценарии (use-case);
- контракты взаимодействия между слоями.
Разработка всегда начинается с domain, потому что:
- UI не должен диктовать бизнес-логику;
- инфраструктура (
data) — это деталь реализации; - типы и контракты формируют «язык» системы.
Контракты важнее реализации
В примере сначала создаются:
- DTO — формат данных, возвращаемых бизнес-сценарием;
- Port — входные параметры use-case;
- Repository interface — абстракция доступа к данным;
- Use-case interface — контракт бизнес-сценария.
Только после этого появляются:
- конкретные use-case;
- репозитории;
- React-код.
Это позволяет:
- легко менять источник данных;
- тестировать use-case изолированно;
- не связывать бизнес-логику с UI или HTTP.
Почему используется пагинация через common
Пагинация — это сквозная концепция, которая может использоваться в разных модулях (users, products, orders и т.д.).
Поэтому:
IBasePaginationPortIBaseGetPaginationDto
Вынесены в domain/common.
Такой подход:
- предотвращает дублирование;
- формирует единый стандарт API;
- упрощает масштабирование проекта.
Роль use-case
Use-case — это чёрный ящик бизнес-логики:
- принимает входные данные через порт;
- работает только через интерфейсы;
- возвращает DTO;
- не знает ничего о React, HTTP или TanStack Query.
В данном примере GetAllUsersUseCase:
- не содержит логики пагинации UI;
- не знает, откуда приходят данные;
- просто координирует бизнес-сценарий.
Почему BaseUsersUseCase
BaseUsersUseCase — это технический базовый класс, который:
- инкапсулирует работу с репозиторием;
- уменьшает дублирование кода;
- упрощает добавление новых use-case.
При появлении новых сценариев:
GetUserByIdCreateUserDeleteUser
Они смогут использовать тот же базовый класс.
Разделение request и presenter в App-слое
В слое app логика разделена на два уровня:
request
- отвечает за асинхронные операции;
- использует TanStack Query;
- знает про кэш, статусы, рефетчи;
- вызывает use-case.
presenter
- агрегирует данные;
- управляет формами и состоянием UI;
- подготавливает данные для компонентов;
- не содержит JSX.
Такое разделение:
- упрощает тестирование;
- делает код предсказуемым;
- снижает связанность компонентов.
Почему TanStack Query используется только в app
TanStack Query — это UI-инструмент.
Он:
- управляет состоянием загрузки;
- кеширует данные;
- синхронизирует UI с сервером.
Практическая часть
1. Типизация данных
В базе данных у пользователя есть поля: id, name, email, avatar_url, password.
Пароль не должен приходить с API, поэтому типизируем только первые 4 поля.
Файл: src/domain/user/interface/dto/index.ts
interface IBaseUserDto {
id: string
name: string
email: string
avatar_url: string
}
type IUserArrayDto = Array<IBaseUserDto>
type IGetAllUserDto = IBaseGetPaginationDto<IUserArrayDto>
export type {IBaseUserDto, IGetAllUserDto}
2. Контракт порта для пагинации
Простая пагинация, вынесенная в common для переиспользования в других модулях.
Файл: src/domain/user/interface/port/index.ts
import type {IBasePaginationPort} from '@/domain/common/interface'
type IGetAllUserPort = IBasePaginationPort
export type {IGetAllUserPort}
Файл: src/domain/common/interface/index.ts
interface IBaseGetPaginationDto<T> {
count: number
data: T
page: number
}
interface IBasePaginationPort {
page: number
limit: number
}
export type {IBaseGetPaginationDto, IBasePaginationPort}
Контракты вынесены в common, чтобы их можно было использовать повторно.
3. Контракт репозитория
Файл: src/domain/user/interface/repository/index.ts
import type { IGetAllUserDto } from '../dto'
import type { IGetAllUserPort } from '../port'
interface IUserRepository {
getAll: (port: IGetAllUserPort) => Promise<IGetAllUserDto>
}
export type { IUserRepository }
4. Контракт use-case
Файл: src/domain/user/interface/use-case/index.ts
import type { IUseCase } from '@/domain/common/http/use-case'
import type { IGetAllUserDto } from '../dto'
import type { IGetAllUserPort } from '../port'
type IGetAllUsersUseCase = IUseCase<IGetAllUserPort, IGetAllUserDto>
export type { IGetAllUsersUseCase }
Файл: src/domain/common/http/use-case/index.ts
interface IUseCase<TPort, TResponse> {
execute: (port: TPort) => Promise<TResponse>
}
export type { IUseCase }
Для абстракции используем дженерики.
5. Реализация use-case
Файл: src/domain/user/use-case/get-all/index.ts
import type { IGetAllUserDto } from '@/domain/user/interface/dto'
import type { IGetAllUsersUseCase } from '@/domain/user/interface/use-case'
import type { IGetAllUserPort } from '@/domain/user/interface/port'
import { BaseUsersUseCase } from '@/domain/user/common/use-case'
class GetAllUsersUseCase extends BaseUsersUseCase implements IGetAllUsersUseCase {
public async execute(port: IGetAllUserPort): Promise<IGetAllUserDto> {
return this._repository.getAll(port)
}
}
export { GetAllUsersUseCase }
Файл: src/domain/user/common/use-case/index.ts
import type { IUserRepository } from '@/domain/user/interface/repository'
class BaseUsersUseCase {
protected readonly _repository: IUserRepository
constructor(UsersRepository: IUserRepository) {
this._repository = UsersRepository
}
}
export { BaseUsersUseCase }
BaseUsersUseCase уменьшает дублирование кода и позволяет переиспользовать репозиторий для других use-case.
6. Слой Data (FAKE API)
Файл: src/data/repository/user/index.ts
import type { IBaseHttpService } from '@/data/repository/common'
import type { IGetAllUserDto } from '@/domain/user/interface/dto'
import type { IUserRepository } from '@/domain/user/interface/repository'
import type { IGetAllUserPort } from '@/domain/user/interface/port'
import { BaseRepository } from '@/data/repository/common'
class UserRepository extends BaseRepository implements IUserRepository {
constructor(httpService: IBaseHttpService) {
super(httpService)
}
async getAll({ page, limit }: IGetAllUserPort): Promise<IGetAllUserDto> {
const createArray = times(random(TEN, HUNDRED))
return new Promise((resolve) => {
resolve({
count: createArray.length,
data: createArray
.map((_, index) => ({
id: index.toString(),
name: `User ${index}`,
email: `text@mail.com${index}`,
avatar_url: '',
}))
.slice(limit * page, limit * page + limit),
page,
})
})
}
}
export { UserRepository }
Файл: src/data/singleton/index.ts
import { HTTP_APP_SERVICE } from '@/data/repository/common'
import { UserRepository } from '@/data/repository/user'
const USER_REPOSITORY = new UserRepository(HTTP_APP_SERVICE)
export { USER_REPOSITORY }
7. Слой App (TanStack Query)
Файл: src/app/modules/user/case/table/case/request/index.ts
import { useQuery } from '@tanstack/react-query'
import { EQueryKey } from '@/domain/common/query/enum/query'
import type { IGetAllUserDto } from '@/domain/user/interface/dto'
import type { IGetAllUserOptions } from '../interface'
import type { IGetAllUserPort } from '@/domain/user/interface/port'
import { GetAllUsersUseCase } from '@/domain/user/use-case/get-all'
import { USER_REPOSITORY } from '@/data/singleton'
const useCase = new GetAllUsersUseCase(USER_REPOSITORY)
const useGetAllUserRequest = (port: IGetAllUserPort, options?: IGetAllUserOptions) => {
const callback = async (): Promise<IGetAllUserDto> => {
return useCase.execute(port)
}
return useQuery({ queryFn: callback, queryKey: [EQueryKey.GET_ALL_USER, port], ...options })
}
export { useGetAllUserRequest }
8. Presenter
Файл: src/app/modules/user/case/table/case/presenter/index.ts
import { useStore } from '@tanstack/react-form'
import { useAppForm } from '@/app/tools/provider/tanstack-form'
import { useGetAllUserRequest } from '../request'
import type { IGetAllUserOptions } from '../interface'
import type { IGetAllUserDto } from '@/domain/user/interface/dto'
const initialDataValue: IGetAllUserDto = { page: 0, data: [], count: 0 }
const useGetAllUserPresenter = (options?: IGetAllUserOptions) => {
const form = useAppForm({
defaultValues: {
pagination: {
page: 0,
limit: 10,
},
},
})
const { pagination } = useStore(form.store, (state) => state.values)
const { data = initialDataValue, ...props } = useGetAllUserRequest(pagination, options)
return {
data,
form,
...props,
}
}
export { useGetAllUserPresenter }