Переводчик: В последнее время я изучал наслоение архитектуры фронтенда, и я получил некоторые знания DDD/Clean Architecture.Я увидел эту статью в среде, которая меня очень вдохновила, и я специально перевел ее и поделился с вами. В будущем я также буду интегрировать связанные идеи в свой проект передовой веб-практики.GitHub.com/MCU король/шаблоны…
Оригинальная ссылкаmedium.com/Killing О, я настоящим…
Статья впервые опубликована в моем блогеGithub.com/mcu king / нет ...
Как создать пакет для управления бизнес-правилами вашего приложения, вызовами API, localStorage и изменить интерфейсную структуру по мере необходимости.
Одностраничные приложения были основой фронтенд-разработки в течение последних нескольких лет и с каждым днем становятся все более сложными. Эта сложность открывает возможности для роста фреймворков и библиотек, которые предоставляют различные решения для разработчиков интерфейса. AngularJS, React, Redux, Vue, Vuex, Ember — это лишь несколько доступных вариантов.
Команда выберет любой фреймворк --car2go использует Vue.js для новых проектов-- но как только приложение становится более сложным, термин "рефакторинг" становится кошмаром для любого разработчика. Часто бизнес-логика тесно связана с выбором фреймворка, и перестройка всего клиентского приложения с нуля может привести к тому, что команды разрабатывают и тестируют бизнес-логику неделями (или месяцами).
Этой ситуации можно избежать, отделив бизнес-логику от выбора фреймворка. Я покажу простой, но эффективный способ добиться этого разделения, готовый перестроить ваш SPA с нуля, используя лучшие фреймворки, когда вы захотите!
Примечание. Я напишу пример на TypeScript, как мы это делаем в веб-команде car2go. Конечно, также можно использовать ES6, Vanilla JS и т. д.
A little bit of Clean Architecture
использоватьClean ArchitectureКонцепция, этот пакет будет состоять из 4 разных частей:
Entities
Эта часть будет содержать модель бизнес-объектов, интерфейс данных. В этом разделе могут быть реализованы правила проверки атрибутов.
Interactors
Этот раздел будет содержать бизнес-правила.
Services
В этой части будут рассмотрены вызовы API, обработка LocalStorage и т. д.
Exposers
Эта часть предоставляет приложению методы Interactors.
Сторонник чистой архитектуры (CA) сказал бы, что это вообще не CA, и может быть прав, но, глядя на картину концентрических слоев, можно связать с ней эту архитектурную модель.
- Entities -> Enterprise Business Rules
- Interactors -> Application Business Rules
- Services and Exposers -> Interface Adapters
Принцип инверсии зависимостей для ссылок на сервисы в InteractorsDependency Inversion PrincipleЕсть и границы.
Эта простая архитектура упрощает написание вещей для имитации, тестирования и реализации.
Code!!!
Этот пример проекта можно клонировать из:
мы будем использоватьjsonplaceholderAPI создает пакет для получения, создания и сохранения сообщений.
Project structure
/showroom # A Vuejs app to test and document package usage
/playground # A simple usage example in NodeJS
/src
/common
/entities
/exposers
/interactors
/services
__mocks__
Эта исходная папка организована таким образом, что можно увидеть каждый слой, а также ее можно организовать по функциям.
Common folder
Эта папка содержит общие модули, которые можно использовать в разных слоях. Например: класс HttpClient — создайте экземпляр axios и абстрагируйте некоторые связанные методы.
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
export interface IHttpClient {
get: <T>(url: string, config?: AxiosRequestConfig) => Promise<T>;
post: <T>(url: string, data?: any, config?: AxiosRequestConfig) => Promise<T>;
patch: <T>(
url: string,
data?: any,
config?: AxiosRequestConfig
) => Promise<T>;
}
class HttpClient implements IHttpClient {
private _http: AxiosInstance;
constructor() {
this._http = axios.create({
baseURL: 'https://jsonplaceholder.typicode.com',
headers: {
'Content-Type': 'application/json'
}
});
}
public async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse = await this._http.get(url, config);
return response.data;
}
public async post<T>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse = await this._http.post(url, data, config);
return response.data;
}
public async patch<T>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse = await this._http.patch(url, data, config);
return response.data;
}
}
export const httpClient: IHttpClient = new HttpClient();
Entities
В этой части мы создадим интерфейс и класс бизнес-объекта. Если для этого объекта нужно иметь какие-то правила, лучше реализовать их здесь (не обязательно). Но также можно просто экспортировать интерфейс данных, а затем реализовать проверку в Interactors.
Чтобы проиллюстрировать это, теперь создайте интерфейс данных и класс бизнес-объекта в Post.
Объект данных JSONPlaceholder Post имеет 4 свойства: id, userId, title и body. Мы проверим заголовок и тело, например:
-
Название не может быть пустым и не должно превышать 256 символов;
-
тело не может быть пустым и содержать не менее 10 символов;
В то же время мы хотим разделить валидирующие свойства (ранее валидирующие), обеспечивающие дополнительную валидацию и ввод данных в объекты. Исходя из этого, мы можем придумать некоторые функции для тестирования.
// Post business object
- copies an object data into a Post instance
- title is invalid for empty string
- title is invalid using additional validator
- title is invalid for long titles
- title is valid
- title is valid using additional validation
- body is invalid for strings with less than 10 characters
- body is invalid using additional validation
- body is valid
- body is valid using additional validation
- post is invalid without previous validation
- post is valid without previous validation
- post is invalid with previous title validation
- post is invalid with previous title and body validation, title is valid
- post is invalid with previous title and body validation, body is valid
- post is valid with previous title validation
- post is valid with previous body validation
- post is valid with previous title and body validation
код показывает, как показано ниже:
import { Post, IPost } from './Post';
describe('Test Post entity', () => {
/* tslint:disable-next-line:max-line-length */
const bigString =
'est rerum tempore vitae sequi sint nihil reprehenderit dolor beatae ea dolores neque fugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis qui aperiam non debitis possimus qui neque nisi nulla est rerum tempore vitae sequi sint nihil reprehenderit dolor beatae ea dolores neque fugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis qui aperiam non debitis possimus qui neque nisi nulla';
let post: IPost;
beforeEach(() => {
post = new Post();
});
it('should copy an object data into a Post instance', () => {
const data = {
id: 1,
userId: 3,
title: 'Copy',
body: 'Copied'
};
post.copyData(data);
expect(post.id).toBe(1);
expect(post.userId).toBe(3);
expect(post.title).toBe('Copy');
expect(post.body).toBe('Copied');
});
it('should return title is invalid for empty string', () => {
expect(post.isValidTitle()).toBeFalsy();
});
it('should return title is invalid using additional validator', () => {
post.title = 'New';
expect(
post.isValidTitle((title: string): boolean => {
return title.length > 3;
})
).toBeFalsy();
});
it('should return title is invalid for long titles', () => {
post.title = bigString;
expect(post.isValidTitle()).toBeFalsy();
});
it('should return title is valid', () => {
post.title = 'New post';
expect(post.isValidTitle()).toBeTruthy();
});
it('should return title is valid using additional validation', () => {
post.title = 'Lorem ipsum';
expect(
post.isValidTitle((title: string) => {
return title.indexOf('dolor') < 0;
})
).toBeTruthy();
});
it('should return body is invalid for strings with less than 10 characters', () => {
post.body = 'Lorem ip';
expect(post.isValidBody()).toBeFalsy();
});
it('should return body is invalid using additional validation', () => {
post.body = 'Lorem ipsum dolor sit amet';
expect(
post.isValidBody((body: string): boolean => {
return body.length > 30;
})
).toBeFalsy();
});
it('should return body is valid', () => {
post.body = 'Lorem ipsum dolor sit amet';
expect(post.isValidBody()).toBeTruthy();
});
it('should return body is valid using additional validation', () => {
post.body = 'Lorem ipsum sit amet';
expect(
post.isValidBody((body: string): boolean => {
return body.indexOf('dolor') < 0;
})
).toBeTruthy();
});
it('should return post is invalid without previous validation', () => {
expect(post.isValid()).toBeFalsy();
});
it('should return post is valid without previous validation', () => {
post.title = 'Lorem ipsum dolor sit amet';
post.body = bigString;
expect(post.isValid()).toBeTruthy();
});
it('should return post is invalid with previous title validation', () => {
post.title = 'Lorem ipsum dolor';
post.body = bigString;
expect(
post.isValidTitle((title: string): boolean => {
return title.indexOf('dolor') < 0;
})
).toBeFalsy();
expect(post.isValid()).toBeFalsy();
});
it('should return post is invalid with previous body validation', () => {
post.title = 'Lorem ipsum dolor';
post.body = 'Invalid body';
expect(
post.isValidBody((body: string): boolean => {
return body.length > 20;
})
).toBeFalsy();
expect(post.isValid()).toBeFalsy();
});
it('should return post is invalid with previous title and body validation, title is valid', () => {
post.title = 'Lorem ipsum dolor';
post.body = bigString;
expect(post.isValidTitle()).toBeTruthy();
expect(
post.isValidBody((body: string): boolean => {
return body.length < 300;
})
).toBeFalsy();
expect(post.isValid()).toBeFalsy();
});
it('should return post is invalid with previous title and body validation, body is valid', () => {
post.title = 'Lorem ipsum dolor';
post.body = bigString;
expect(
post.isValidTitle((title: string): boolean => {
return title.indexOf('dolor') < 0;
})
).toBeFalsy();
expect(post.isValidBody()).toBeTruthy();
expect(post.isValid()).toBeFalsy();
});
it('should return post is valid with previous title validation', () => {
post.title = 'Lorem ipsum dolor';
post.body = bigString;
expect(post.isValidTitle()).toBeTruthy();
expect(post.isValid()).toBeTruthy();
});
it('should return post is valid with previous body validation', () => {
post.title = 'Lorem ipsum dolor';
post.body = bigString;
expect(post.isValidBody()).toBeTruthy();
expect(post.isValid()).toBeTruthy();
});
it('should return post is valid with previous title and body validation', () => {
post.title = 'Lorem ipsum';
post.body = bigString;
expect(
post.isValidTitle((title: string): boolean => {
return title.indexOf('dolor') < 0;
})
).toBeTruthy();
expect(post.isValidBody()).toBeTruthy();
expect(post.isValid()).toBeTruthy();
});
});
Теперь приступим к реализации интерфейса и класса Post.
Самая сложная часть — при проверке достоверности сообщения вам нужно проверить, был ли атрибут сообщения проверен ранее. Если перед этим выполняется какая-либо проверка, то внутренняя проверка не используется.
_validTitle
а также_validBody
Свойство должно быть инициализировано как неопределенное, и при использовании предыдущего метода проверки оно получит логическое значение.
Это обеспечивает проверку свойств в режиме реального времени на уровне представления и дополнительную проверку с некоторыми интересными сторонними библиотеками — в нашем примере приложения (демонстрационный зал) используйтеVeeValidate.
export interface IPost {
userId: number;
id: number;
title: string;
body: string;
copyData?: (data: any) => void;
isValidTitle?: (additionalValidator?: (value: string) => boolean) => boolean;
isValidBody?: (additionalValidator?: (value: string) => boolean) => boolean;
isValid?: () => boolean;
}
export class Post implements IPost {
public userId: number = 0;
public id: number = 0;
public title: string = '';
public body: string = '';
/**
* Private properties to store validation states
* when the application validates fields separetely
* and/or use additional validations
*/
private _validTitle: boolean | undefined;
private _validBody: boolean | undefined;
/**
* Returns if title property is valid based on the internal validator
* and an optional extra validator
* @memberof Post
* @param validator Additional validation function
* @returns boolean
*/
public isValidTitle(validator?: (value: string) => boolean): boolean {
this._validTitle =
this._validateTitle() && (!validator ? true : validator(this.title));
return this._validTitle;
}
/**
* Returns if body property is valid based on the internal validator
* and an optional extra validator
* @memberof Post
* @param validator Additional validation function
* @returns boolean
*/
public isValidBody(validator?: (value: string) => boolean): boolean {
this._validBody =
this._validateBody() && (!validator ? true : validator(this.body));
return this._validBody;
}
/**
* Returns if the post object is valid
* It should not use internal (private) validation methods
* if previous property validation methods were used
* @memberof Post
* @returns boolean
*/
public isValid(): boolean {
if (
(this._validTitle && this._validBody) ||
(this._validTitle &&
this._validBody === undefined &&
this._validateBody()) ||
(this._validTitle === undefined &&
this._validateTitle() &&
this._validBody) ||
(this._validTitle === undefined &&
this._validBody === undefined &&
this._validateTitle() &&
this._validateBody())
) {
return true;
}
return false;
}
/**
* Copy propriesties from an object to
* instance properties
* @memberof Post
* @param data object
*/
public copyData(data: any): void {
const { id, userId, title, body } = data;
this.id = id;
this.userId = userId;
this.title = title;
this.body = body;
}
/**
* Validates title property
* It should be not empty and should not have more than 256 characters
* @memberof Post
* @returns boolean
*/
private _validateTitle(): boolean {
return this.title.trim() !== '' && this.title.trim().length < 256;
}
/**
* Validates body property
* It should not be empty and should not have less than 10 characters
* @memberof Post
* @returns boolean
*/
private _validateBody(): boolean {
return this.body.trim() !== '' && this.body.trim().length > 10;
}
}
Services
Сервисы — это классы, используемые для загрузки/отправки данных через API, операции localStorage, соединения через сокеты. Класс PostService довольно прост.
import { httpClient } from '../common/HttpClient';
import { IPost } from '../entities/Post';
export interface IPostService {
getPosts: () => Promise<IPost[]>;
createPost: (data: IPost) => Promise<IPost>;
savePost: (data: IPost) => Promise<IPost>;
}
export class PostService implements IPostService {
public async getPosts(): Promise<IPost[]> {
const response = await httpClient.get<IPost[]>('/posts');
return response;
}
public async createPost(data: IPost): Promise<IPost> {
const { title, body } = data;
const response = await httpClient.post<IPost>('/posts', { title, body });
return response;
}
public async savePost(data: IPost): Promise<IPost> {
const { id, title, body } = data;
const response = await httpClient.patch<IPost>(`/posts/${id}`, {
title,
body
});
return response;
}
}
Макет PostService также очень прост,кликните сюда.
/* tslint:disable:no-unused */
import { IPost } from '../../entities/Post';
export class PostService {
public async getPosts(): Promise<IPost[]> {
return [
{
userId: 1,
id: 1,
title: 'Lorem ipsum',
body: 'Dolor sit amet'
},
{
userId: 1,
id: 2,
title: 'Lorem ipsum dolor',
body: 'Dolor sit amet'
}
];
}
public async createPost(data: IPost): Promise<IPost> {
return {
...data,
id: 3,
userId: 1
};
}
public async savePost(data: IPost): Promise<IPost> {
if (data.id !== 3) {
throw new Error();
}
return {
...data,
id: 3,
userId: 1
};
}
}
Interactors
Interactors — это классы, которые обрабатывают бизнес-логику. Он отвечает за проверку того, что все условия, требуемые конкретным пользователем, соблюдены — в основном, Interactors должны реализовать бизнес-вариант использования.
В этом пакете Interactor представляет собой синглтон, который позволяет нам хранить некоторое состояние и избегать ненужных HTTP-вызовов, предоставляя способ сброса свойств состояния приложения (например, восстановление данных поста при потере записей об изменениях), который решает, когда следует добавлять новые данные. быть загруженным (например, сокетное соединение для приложения NodeJS для обновления важного содержимого в режиме реального времени).
Как только только методы интеракторов будут представлены уровню представления, все создание бизнес-объектов будет осуществляться ими.
Мы можем придумать некоторые функции для тестирования.
// PostInteractor class
- returns a new post object
- gets a list of posts
- returns the existing posts list (stored state)
- resets the instance and throws an error while fetching posts
- creates a new post
- throws there is no post data
- throws post data is invalid when creating post
- throws a service error when creating a post
- saves a new post
- throws a service error when saving a post
код показывает, как показано ниже:
import { IPost, Post } from '../entities/Post';
import PostInteractor, { IPostInteractor } from './PostInteractor';
import { PostService } from '../services/PostService';
jest.mock('../services/PostService');
describe('PostInteractor', () => {
let interactor: IPostInteractor = PostInteractor.getInstance();
const getPosts = PostService.prototype.getPosts;
const createPost = PostService.prototype.createPost;
beforeEach(() => {
PostService.prototype.getPosts = getPosts;
PostService.prototype.createPost = createPost;
});
it('should return a new post object', () => {
const post = interactor.initPost();
expect(post.title).toBe('');
expect(post.isValidTitle()).toBeFalsy();
post.title = 'Valid title';
expect(post.isValidTitle()).toBeTruthy();
});
it('should get a list of posts', async () => {
PostService.prototype.getPosts = jest.fn().mockImplementationOnce(() => {
return getPosts();
});
const posts = await interactor.getPosts();
const spy = jest.spyOn(PostService.prototype, 'getPosts');
expect(spy).toHaveBeenCalled();
expect(posts.length).toBe(2);
expect(posts[0].title).toContain('Lorem ipsum');
spy.mockClear();
});
it('should return the existing posts list', async () => {
PostService.prototype.getPosts = jest.fn().mockImplementationOnce(() => {
throw new Error();
});
const posts = await interactor.getPosts();
const spy = jest.spyOn(PostService.prototype, 'getPosts');
expect(spy).not.toHaveBeenCalled();
expect(posts.length).toBe(2);
expect(posts[0].title).toContain('Lorem ipsum');
spy.mockClear();
});
it('should reset the instance and throw an error while fetching posts', async () => {
PostInteractor.resetInstance();
interactor = PostInteractor.getInstance();
PostService.prototype.getPosts = jest.fn().mockImplementationOnce(() => {
throw new Error();
});
let error;
try {
await interactor.getPosts();
} catch (err) {
error = err;
}
expect(error.message).toBe('Error fetching posts');
});
it('should create a new post', async () => {
const data: IPost = new Post();
data.title = 'Lorem ipsum dolor';
data.body = 'Dolor sit amet';
const post = await interactor.createPost(data);
expect(post).toBeDefined();
expect(post.id).toBe(3);
expect(post.title).toEqual(data.title);
expect(post.title).toEqual(data.title);
});
it('should throw there is no post data', async () => {
let post;
let error;
try {
post = await interactor.createPost(undefined);
} catch (err) {
error = err;
}
expect(error.message).toBe('No post data provided');
});
it('should throw post data is invalid when creating post', async () => {
const data: IPost = new Post();
data.body = 'Dolor sit amet';
let post;
let error;
try {
post = await interactor.createPost(data);
} catch (err) {
error = err;
}
expect(error.message).toBe('The post data is invalid');
});
it('should throw a service error when creating a post', async () => {
PostService.prototype.createPost = jest.fn().mockImplementationOnce(() => {
throw new Error();
});
let error;
const data: IPost = new Post();
data.title = 'Lorem ipsum dolor';
data.body = 'Dolor sit amet';
try {
await interactor.createPost(data);
} catch (err) {
error = err;
}
expect(error).toBeDefined();
expect(error.message).toBe('Server error when trying to create the post');
});
it('should save a new post', async () => {
const data: IPost = new Post();
data.userId = 1;
data.id = 3;
data.title = 'Lorem ipsum dolor edited';
data.body = 'Dolor sit amet';
const post = await interactor.savePost(data);
expect(post).toBeDefined();
expect(post.id).toBe(3);
expect(post.title).toEqual(data.title);
expect(post.title).toEqual(data.title);
});
it('should throw a service error when saving a post', async () => {
const data: IPost = new Post();
data.userId = 1;
data.id = 2;
data.title = 'Lorem ipsum dolor edited';
data.body = 'Dolor sit amet';
let error;
try {
await interactor.savePost(data);
} catch (err) {
error = err;
}
expect(error).toBeDefined();
expect(error.message).toBe('Server error when trying to save the post');
});
});
Теперь приступим к реализации интерфейса и класса PostInteractor.
import { IPost, Post } from '../entities/Post';
import { IPostService, PostService } from '../services/PostService';
export interface IPostInteractor {
initPost: () => IPost;
getPosts: () => Promise<IPost[]>;
createPost: (data: IPost) => Promise<IPost>;
savePost: (data: IPost) => Promise<IPost>;
}
export default class PostInteractor implements IPostInteractor {
private static _instance: IPostInteractor = new PostInteractor(
new PostService()
);
public static getInstance(): IPostInteractor {
return this._instance;
}
public static resetInstance(): void {
this._instance = new PostInteractor(new PostService());
}
private _posts: IPost[];
private constructor(private _service: IPostService) {}
public initPost(): IPost {
return new Post();
}
public async getPosts(): Promise<IPost[]> {
if (this._posts !== undefined) {
return this._posts;
}
let response;
try {
response = await this._service.getPosts();
} catch (err) {
throw new Error('Error fetching posts');
}
this._posts = response;
return this._posts;
}
public async createPost(data: IPost): Promise<IPost> {
this._checkPostData(data);
let response;
try {
response = await this._service.createPost(data);
} catch (err) {
throw new Error('Server error when trying to create the post');
}
return response;
}
public async savePost(data: IPost): Promise<IPost> {
this._checkPostData(data);
let response;
try {
response = await this._service.savePost(data);
} catch (err) {
throw new Error('Server error when trying to save the post');
}
return response;
}
private _checkPostData(data: IPost): void {
if (!data) {
throw new Error('No post data provided');
}
if (data.isValid && !data.isValid()) {
throw new Error('The post data is invalid');
}
}
}
Exposers
Теперь мы готовы представить наш пакет приложению. Причина использования экспозиторов заключается в том, что публикуемые нами API-интерфейсы используются независимо от реализации, экспортируя набор методов и используя разные имена в зависимости от среды или приложения.
Обычно разоблачители просто экспортируют эти методы. Так что нам не нужно добавлять логику.
import PostInteractor, { IPostInteractor } from '../interactors/PostInteractor';
import { IPost } from '../entities/Post';
export interface IPostExposer {
initPost: () => IPost;
posts: Promise<IPost[]>;
createPost: (data: IPost) => Promise<IPost>;
savePost: (data: IPost) => Promise<IPost>;
}
class PostExposer implements IPostExposer {
constructor(private _interactor: IPostInteractor) {}
public initPost(): IPost {
return this._interactor.initPost();
}
public get posts(): Promise<IPost[]> {
return this._interactor.getPosts();
}
public createPost(data: IPost): Promise<IPost> {
return this._interactor.createPost(data);
}
public savePost(data: IPost): Promise<IPost> {
return this._interactor.savePost(data);
}
}
/* tslint:disable:no-unused */
export const postExposer: IPostExposer = new PostExposer(
PostInteractor.getInstance()
);
Exporting the library
export { IPost } from './entities/Post';
export * from './exposers/PostExposer';
Using the library
Для проекта выставочного зала мы напрямую связываем пакет с проектом. Но он может публиковать в npm, приватный репозиторий, устанавливать через GitHub, GitLab. Это простой пакет npm, который работает как любой другой пакет.
можно перейти в папку/showroom
Запустите шоу-рум.
Затем при бегеnpm link ../
бежать раньшеnpm install
чтобы гарантировать, что пакет будет установлен правильно и не будет удален npm.
npm link
Команда очень полезна при разработке библиотек, она автоматически обновит зависимую папку node_modules, как только изменится сборка пакета.
живая демонстрация выставочного залакликните сюда.
Простой пример использования NodeJS (мы можем сделать это и на бэкэнде) можно найти по адресуplaygound
папка найдена. Чтобы проверить это, просто перейдите в эту папку и запуститеnpm link ../
, затем запуститеnode simple-usage.js
, а затем просмотреть результаты в консоли.
const postExposer = require('business-rules-package').postExposer;
let posts;
let post;
(async () => {
try {
posts = await postExposer.posts;
console.log(`${posts.length} posts where loaded`);
} catch (err) {
console.log(err.message);
}
post = postExposer.initPost();
post.title = 'Title example';
post.body = 'Should have more than 10 characters';
try {
post = await postExposer.createPost(post);
console.log(`Created post with id ${post.id}`);
} catch (err) {
console.log(err.message);
}
// set a random post to edit
post = postExposer.initPost();
post.copyData(posts[47]);
post.title += ' edited';
try {
post = await postExposer.savePost(post);
console.log(`New title is '${post.title}'`);
} catch (err) {
console.log(err.message);
}
})();
Если у вас есть какие-либо сомнения, предложения или разные мнения, оставьте сообщение, и мы вместе обсудим архитектуру внешнего интерфейса. Приятно видеть разные точки зрения на один и тот же вопрос. Это также всегда было местом, где можно было узнать что-то новое. Спасибо за прочтение! :)