Hello, web world! Enterprise edition

от автора

Отказ от ответственности:

Эта статья написана за 3 часа нейросетями, пока ждал когда коллеги отойдут от вчерашнего корпоратива. Код не проверялся и полон нейроглюков. Автор не фронт-эндер. Здесь интересна только композиция исходной задачи на мелкие подзадачи.
Извините за форматирование, никак не привыкну к здешнему формату.

INTRO
Я архитектор и бэк программист. Понадобилось реализовать модуль с развитым фронт-эндом. Оказалось что как единый компонент его реализовать слишком сложно. Попробую разбить на компоненты, особенно на фронт-энде.

Постановка моей задачи
Визард из 3 шагов.
— На первом этапе необходимо подать запрос пользователя с выбором и поиском по справочникам и вводом данных.
— Второй этап: несколько сущностей в виде вкладок. Кнопки добавления, удаления и копирования вкладки.
— Во вкладке форма с выбором и поиском по справочникам и вводом данных.
— При переходе на вкладку данные валидируются и сохраняются на сервере. Данные для справочников берутся с сервера.
— На третьем шаге отчет для проверки введенных данных.

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

ПОСЛЕДОВАТЕЛЬНОСТЬ РАБОТ:

Бэкенд API:

Проектирование API контрактов
Реализация API endpoints
Документация (OpenAPI/Swagger)
Тестирование API отдельно

Фронтенд с моками:

Разработка UI компонентов
Создание API клиента с моками
Тестирование компонентов изолированно
Тестирование взаимодействия компонентов

Фронтенд с реальными данными:

Реализация реальных API вызовов
Обработка загрузки/ошибок
Тестирование с реальным API

Интеграция:

E2E тестирование
Производительность
Мониторинг

Архитектурные и технологические решения

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

Технологический стек:

  • Бэкенд:

    • Язык программирования: C# (на самом деле пофиг, бека тут мало)

    • Фреймворк: ASP.NET Core Web API для создания RESTful API.

    • Доступ к данным: Entity Framework Core или другой ORM для работы с базой данных.

    • База данных: SQL Server, PostgreSQL или MongoDB в зависимости от требований.

    • Документация API: Swagger/OpenAPI для автоматической генерации документации и контрактов.

    • Тестирование: xUnit, NUnit или MSTest для модульного и интеграционного тестирования.

  • Фронтенд:

    • Язык программирования: TypeScript для типизации и улучшенной надежности кода.

    • Библиотека UI: React для построения пользовательского интерфейса.

    • Управление состоянием: Redux или React Context API для управления состоянием приложения. (эти детали не описаны)

    • Роутинг: React Router для управления навигацией между шагами визарда.

    • Стилизация: CSS-in-JS (Emotion, Styled Components) или CSS Modules для модульной стилизации компонентов.

    • Тестирование: Jest и React Testing Library для модульного и интеграционного тестирования компонентов.

Преимущества предлагаемой архитектуры:

  • Разделение ответственности (SoC): Каждый модуль отвечает за свою область, что упрощает разработку и поддержку.

  • Тестируемость: Легко проводить Unit-тестирование компонентов и API.

  • Масштабируемость: Возможность расширения функциональности и добавления новых модулей.

  • Переиспользование кода: Общие типы и константы используются как на Frontend, так и на Backend.

  • Параллельная разработка: Frontend и Backend могут разрабатываться независимо.

Ключевые моменты:

  • Использование API-First подхода.

  • Генерация TypeScript типов из C# моделей (рекомендуется с помощью NSwag или OpenAPI Generator).

  • Использование моков на этапе разработки Frontend.

  • Покрытие кода тестами.

Структура солюшна.

Простой вариант
Solution/
├── Backend/
│ ├── UserForm.API/ # Основной проект API
│ │ ├── Controllers/
│ │ │ └── FormsController.cs # Эндпоинты API
│ │ ├── Models/ # Модели данных, общая бизнес логика
│ │ │ └── FormModel.cs
│ │ ├── Services/ # Бизнес-логика конкретных вариантов использования
│ │ │ └── FormService.cs
│ │ └── Program.cs
│ │
│ └── UserForm.Tests/ # Тесты бэкенда

├── Frontend/
│ ├── src/
│ │ ├── api/ # Работа с API
│ │ │ ├── formApi.ts # Клиент API
│ │ │ └── types.ts # Типы данных
│ │ │
│ │ ├── components/ # React компоненты
│ │ │ ├── FormWizard.tsx # Основной компонент визарда
│ │ │ └── FormStep.tsx # Компонент шага
│ │ │
│ │ └── App.tsx
│ │
│ └── tests/ # Тесты фронтенда

└── Shared/ # Общий код
└── Types/ # Общие типы
├── FormTypes.cs
└── FormTypes.ts

Сложный вариант

Solution/
├── .github/ # GitHub Actions, CI/CD
│ └── workflows/

├── Backend/
│ ├── UserForm.API/ # Web API проект
│ │ ├── Controllers/
│ │ │ ├── FormsController.cs
│ │ │ └── BaseController.cs
│ │ ├── Middleware/
│ │ │ ├── ErrorHandlingMiddleware.cs
│ │ │ └── LoggingMiddleware.cs
│ │ ├── Configuration/
│ │ │ └── SwaggerConfig.cs
│ │ ├── Program.cs
│ │ ├── Startup.cs
│ │ └── appsettings.json
│ │
│ ├── UserForm.Core/ # Бизнес-логика
│ │ ├── Models/
│ │ │ ├── Form.cs
│ │ │ └── Step.cs
│ │ ├── Services/
│ │ │ ├── Interfaces/
│ │ │ │ └── IFormService.cs
│ │ │ └── FormService.cs
│ │ ├── Validation/
│ │ │ ├── Validators/
│ │ │ │ └── FormValidator.cs
│ │ │ └── Rules/
│ │ └── Exceptions/
│ │ └── BusinessException.cs
│ │
│ ├── UserForm.Infrastructure/ # Доступ к данным
│ │ ├── Data/
│ │ │ ├── Repositories/
│ │ │ │ ├── IFormRepository.cs
│ │ │ │ └── FormRepository.cs
│ │ │ └── Configurations/
│ │ │ └── FormConfiguration.cs
│ │ ├── Persistence/
│ │ │ ├── FormDbContext.cs
│ │ │ └── Migrations/
│ │ └── Services/
│ │ └── External/ # Внешние сервисы
│ │
│ ├── UserForm.Shared/ # Общие DTO и контракты
│ │ ├── DTOs/
│ │ │ ├── FormDTO.cs
│ │ │ └── ValidationDTO.cs
│ │ ├── Constants/
│ │ │ └── ApiRoutes.cs
│ │ └── Extensions/
│ │
│ └── Tests/
│ ├── UserForm.UnitTests/
│ │ ├── Services/
│ │ └── Validators/
│ ├── UserForm.IntegrationTests/
│ │ └── API/
│ └── UserForm.E2ETests/

├── Frontend/
│ ├── public/
│ │ └── index.html
│ │
│ ├── src/
│ │ ├── api/ # API клиенты
│ │ │ ├── types.ts
│ │ │ ├── formApi.ts
│ │ │ └── generated/ # Автогенерированные типы
│ │ │
│ │ ├── components/ # React компоненты
│ │ │ ├── common/ # Общие компоненты
│ │ │ │ ├── Button/
│ │ │ │ ├── Input/
│ │ │ │ └── ErrorBoundary/
│ │ │ │
│ │ │ ├── Form/ # Компоненты формы
│ │ │ │ ├── FormWizard/
│ │ │ │ ├── FormStep/
│ │ │ │ └── FormNavigation/
│ │ │ │
│ │ │ └── Layout/ # Компоненты лейаута
│ │ │
│ │ ├── hooks/ # React хуки
│ │ │ ├── useForm.ts
│ │ │ └── useApi.ts
│ │ │
│ │ ├── store/ # Управление состоянием
│ │ │ ├── slices/
│ │ │ └── store.ts
│ │ │
│ │ ├── utils/ # Утилиты
│ │ │ ├── validation.ts
│ │ │ └── formatting.ts
│ │ │
│ │ ├── styles/ # Стили
│ │ │ ├── global.css
│ │ │ └── variables.css
│ │ │
│ │ ├── config/ # Конфигурация
│ │ │ └── api.ts
│ │ │
│ │ ├── App.tsx
│ │ └── index.tsx
│ │
│ ├── tests/ # Тесты
│ │ ├── unit/
│ │ │ └── components/
│ │ ├── integration/
│ │ └── e2e/
│ │
│ ├── .storybook/ # Storybook конфигурация
│ ├── package.json
│ ├── tsconfig.json
│ └── vite.config.ts

├── Shared/ # Общий код
│ ├── Types/ # Общие типы
│ │ ├── FormTypes.cs
│ │ └── FormTypes.ts
│ │
│ └── Constants/ # Общие константы
│ ├── ApiRoutes.cs
│ └── ApiRoutes.ts

├── Tools/ # Инструменты разработки
│ ├── CodeGen/ # Генераторы кода
│ └── Scripts/ # Скрипты сборки/деплоя

├── docs/ # Документация
│ ├── api/
│ ├── architecture/
│ └── deployment/

├── .editorconfig # Настройки редактора
├── .gitignore
├── docker-compose.yml # Docker конфигурация
├── README.md
└── Solution.sln

Заметим, что одни и те же типы (если использовать typescript) используются как на фронте так и на беке.

Разберем пошаговую разработку бэкенда на C#:

  1. Сначала определяем модели и контракты:

    Переиспользование типов фронтенда и бекэнда:

# api-spec.yaml openapi: 3.0.0 info:   title: User Form API   version: 1.0.0 paths:   /api/forms/{id}:     get:       summary: Get form data       parameters:         - name: id           in: path           required: true           schema:             type: string       responses:         200:           description: Form data           content:             application/json:               schema:                 $ref: '#/components/schemas/FormDTO'     put:       summary: Update form data       parameters:         - name: id           in: path           required: true           schema:             type: string       requestBody:         content:           application/json:             schema:               $ref: '#/components/schemas/FormDTO'       responses:         200:           description: Updated form data           content:             application/json:               schema:                 $ref: '#/components/schemas/FormDTO'  components:   schemas:     FormDTO:       type: object       properties:         id:           type: string         step:           type: string           enum: [personal, address, payment]         data:           type: object           additionalProperties: true         status:           type: string           enum: [draft, complete, error] 
  1. Генерируем C# код:

# Используем NSwag nswag openapi2csclient /input:api-spec.yaml /output:Backend/Generated/ApiClient.cs  # Или используем OpenAPI Generator openapi-generator generate -i api-spec.yaml -g aspnetcore -o Backend/Generated 
  1. Генерируем TypeScript код:

# Генерация TypeScript клиента openapi-generator generate -i api-spec.yaml -g typescript-fetch -o Frontend/src/api/generated 
  1. Реализуем API на бэкенде используя сгенерированные интерфейсы:

// Backend/Controllers/FormsController.cs [ApiController] [Route("api/[controller]")] public class FormsController : ControllerBase, IFormsApi {     public async Task> GetForm(string id)     {         // Реализация     }      public async Task> UpdateForm(string id, FormDTO form)     {         // Реализация     } } 
  1. Используем сгенерированный клиент на фронтенде:

// Frontend/src/components/FormWizard.tsx import { FormsApi, FormDTO } from '../api/generated';  export const FormWizard: React.FC<{ id: string }> = ({ id }) => {     const api = new FormsApi();     const [form, setForm] = useState();      useEffect(() => {         api.getForm(id).then(setForm);     }, [id]);      // Остальной код }; 

Преимущества этого подхода:

  1. API контракт становится источником правды

  2. Фронт и бэк могут разрабатываться параллельно

  3. Легко поддерживать совместимость

  4. Автоматическая документация

  5. Типобезопасность на обеих сторонах

Недостатки:
простой контракт проще написать вручную

…вернемся к разработке бекэнда…

2. Создаем интерфейс сервиса на бекэнде:

// Services/IUserFormService.cs public interface IUserFormService {     Task GetFormAsync(string id);     Task SaveFormAsync(string id, UserForm form);     Task ValidateStepAsync(string id, FormStep step, Dictionary data); }  // Services/UserFormService.cs public class UserFormService : IUserFormService { // конструктор с внедрением зависимостей     private readonly IUserFormRepository _repository;     private readonly IValidator _validator;      public UserFormService(IUserFormRepository repository, IValidator validator)     {         _repository = repository;         _validator = validator;     }  // реализации того что обещали в IUserFormService     public async Task GetFormAsync(string id)     {         var form = await _repository.GetByIdAsync(id);         if (form == null)         {             throw new NotFoundException($"Form {id} not found");         }         return form;     }      public async Task SaveFormAsync(string id, UserForm form)     {         form.LastUpdated = DateTime.UtcNow;                  var validationResult = await _validator.ValidateAsync(form);         if (!validationResult.IsValid)         {             form.Status = FormStatus.Error;             form.ValidationErrors = validationResult.Errors                 .Select(e => e.ErrorMessage)                 .ToList();         }          return await _repository.SaveAsync(id, form);     } } 
  1. Реализуем репозиторий:

// Repository/IUserFormRepository.cs public interface IUserFormRepository {     Task GetByIdAsync(string id);     Task SaveAsync(string id, UserForm form); }  // Repository/UserFormRepository.cs public class UserFormRepository : IUserFormRepository {     private readonly IMongoCollection _forms;      public UserFormRepository(IMongoDatabase database)     {         _forms = database.GetCollection("userForms");     }      public async Task GetByIdAsync(string id)     {         return await _forms.Find(f => f.Id == id).FirstOrDefaultAsync();     }      public async Task SaveAsync(string id, UserForm form)     {         await _forms.ReplaceOneAsync(             f => f.Id == id,             form,             new ReplaceOptions { IsUpsert = true }         );         return form;     } } 
  1. Добавляем валидацию:

// Validation/UserFormValidator.cs public class UserFormValidator : AbstractValidator {     public UserFormValidator()     {         RuleFor(x => x.Id).NotEmpty();         RuleFor(x => x.Step).IsInEnum();         RuleFor(x => x.Status).IsInEnum();                  When(x => x.Step == FormStep.Personal, () => {             RuleFor(x => x.Data)                 .Must(HaveRequiredPersonalFields)                 .WithMessage("Missing required personal information");         });     }      private bool HaveRequiredPersonalFields(Dictionary data)     {         return data != null &&                data.ContainsKey("firstName") &&                data.ContainsKey("lastName");     } } 
  1. Создаем контроллер:

// Controllers/UserFormController.cs [ApiController] [Route("api/forms")] public class UserFormController : ControllerBase {     private readonly IUserFormService _formService;     private readonly IMapper _mapper;      public UserFormController(IUserFormService formService, IMapper mapper)     {         _formService = formService;         _mapper = mapper;     }      [HttpGet("{id}")]     public async Task> GetForm(string id)     {         try         {             var form = await _formService.GetFormAsync(id);             return Ok(_mapper.Map(form));         }         catch (NotFoundException ex)         {             return NotFound(ex.Message);         }     }      [HttpPut("{id}")]     public async Task> SaveForm(string id, UserFormDto dto)     {         var form = _mapper.Map(dto);         var savedForm = await _formService.SaveFormAsync(id, form);         return Ok(_mapper.Map(savedForm));     } } 
  1. Настраиваем AutoMapper:
    для автоматического преобразования между разными представлениями объектов, напр-р между внутренними моделями и DTO — в данном случае излишне, убрал

  2. Добавляем тесты:

// Tests/UserFormServiceTests.cs public class UserFormServiceTests {     private readonly Mock _repositoryMock;     private readonly Mock> _validatorMock;     private readonly UserFormService _service;      public UserFormServiceTests()     {         _repositoryMock = new Mock();         _validatorMock = new Mock>();         _service = new UserFormService(_repositoryMock.Object, _validatorMock.Object);     }      [Fact]     public async Task GetForm_WhenExists_ReturnsForm()     {         // Arrange         var form = new UserForm { Id = "test" };         _repositoryMock.Setup(r => r.GetByIdAsync("test"))             .ReturnsAsync(form);          // Act         var result = await _service.GetFormAsync("test");          // Assert         Assert.Equal(form, result);     }      [Fact]     public async Task SaveForm_WithValidData_SavesAndReturnsForm()     {         // Arrange         var form = new UserForm { Id = "test" };         _validatorMock.Setup(v => v.ValidateAsync(It.IsAny(), default))             .ReturnsAsync(new ValidationResult());         _repositoryMock.Setup(r => r.SaveAsync("test", It.IsAny()))             .ReturnsAsync(form);          // Act         var result = await _service.SaveFormAsync("test", form);          // Assert         Assert.Equal(form, result);     } } 
  1. Настройка DI в Startup:

// Startup.cs public void ConfigureServices(IServiceCollection services) {     services.AddControllers();     services.AddAutoMapper(typeof(UserFormProfile));          services.AddScoped();     services.AddScoped();     services.AddScoped, UserFormValidator>();          services.AddSwaggerGen(c =>     {         c.SwaggerDoc("v1", new OpenApiInfo { Title = "UserForm API", Version = "v1" });     }); } 

Шаги проектирования и реализации фронт:

// api/types.ts // Определяем все типы и интерфейсы для API export interface UserFormDTO {   id: string;   step: FormStep;   data: Record;   status: FormStatus;   lastUpdated?: string;   validationErrors?: string[]; }  export type FormStep = 'personal' | 'address' | 'payment'; export type FormStatus = 'draft' | 'complete' | 'error';  // Интерфейс для API клиента export interface UserFormApi {   getFormData(id: string): Promise;   saveFormData(id: string, data: Partial): Promise;   validateStep(id: string, step: FormStep, data: any): Promise; } 
// api/userFormApi.ts // Реализация API клиента с поддержкой моков для разработки import { UserFormApi, UserFormDTO } from './types'; import { mockData } from './mockData';  export class UserFormApiClient implements UserFormApi {   private readonly baseUrl: string;   private readonly useMocks: boolean;    constructor(config = {     baseUrl: process.env.REACT_APP_API_URL,     useMocks: process.env.REACT_APP_USE_MOCKS === 'true'   }) {     this.baseUrl = config.baseUrl;     this.useMocks = config.useMocks;   }    // Получение данных формы   async getFormData(id: string): Promise {     if (this.useMocks) {       // В режиме разработки используем моки       return mockData[id];     }      // В продакшене делаем реальный API запрос     const response = await fetch(`${this.baseUrl}/forms/${id}`);     if (!response.ok) {       throw new Error(`API Error: ${response.statusText}`);     }     return response.json();   }    // Сохранение данных формы   async saveFormData(id: string, data: Partial): Promise {     if (this.useMocks) {       // Имитируем задержку сети       await new Promise(resolve => setTimeout(resolve, 500));       return {         ...mockData[id],         ...data,         lastUpdated: new Date().toISOString()       };     }      const response = await fetch(`${this.baseUrl}/forms/${id}`, {       method: 'PUT',       headers: { 'Content-Type': 'application/json' },       body: JSON.stringify(data)     });      if (!response.ok) {       throw new Error(`Save Error: ${response.statusText}`);     }     return response.json();   } } 
// hooks/useFormData.ts // Хук для работы с данными формы, инкапсулирует логику загрузки и обработки ошибок import { useState, useEffect } from 'react'; import { UserFormApiClient } from '../api/userFormApi'; import { UserFormDTO } from '../api/types';  export function useFormData(formId: string) {   const [data, setData] = useState(null);   const [loading, setLoading] = useState(true);   const [error, setError] = useState(null);    // Загрузка данных при монтировании или изменении formId   useEffect(() => {     const api = new UserFormApiClient();     let mounted = true;      async function loadData() {       try {         const result = await api.getFormData(formId);         if (mounted) {           setData(result);         }       } catch (err) {         if (mounted) {           setError(err as Error);         }       } finally {         if (mounted) {           setLoading(false);         }       }     }      loadData();      // Очистка при размонтировании     return () => {       mounted = false;     };   }, [formId]);    // Функция для сохранения данных   const saveData = async (newData: Partial) => {     setLoading(true);     try {       const api = new UserFormApiClient();       const updated = await api.saveFormData(formId, newData);       setData(updated);       return updated;     } catch (err) {       setError(err as Error);       throw err;     } finally {       setLoading(false);     }   };    return { data, loading, error, saveData }; } 
// components/UserForm/UserForm.tsx // Основной компонент формы, управляет состоянием и навигацией import React, { useState } from 'react'; import { useFormData } from '../../hooks/useFormData'; import { FormStep } from '../../api/types'; import { StepComponents } from './StepComponents'; import { Spinner, ErrorMessage } from '../common'; import './styles.css';  interface UserFormProps {   formId: string;   onComplete?: (data: any) =&gt; void; }  export const UserForm: React.FC = ({ formId, onComplete }) =&gt; {   const { data, loading, error, saveData } = useFormData(formId);   const [currentStep, setCurrentStep] = useState('personal');    // Обработчик перехода к следующему шагу   const handleNext = async (stepData: any) =&gt; {     try {       // Сохраняем данные текущего шага       await saveData({         data: { ...data?.data, ...stepData },         step: getNextStep(currentStep)       });              // Переходим к следующему шагу       setCurrentStep(getNextStep(currentStep));     } catch (err) {       console.error('Failed to save step:', err);     }   };    if (loading) return ;   if (error) return ;   if (!data) return null;    const StepComponent = StepComponents[currentStep];    return (     <div>       {/* Индикатор прогресса */}               {/* Текущий шаг формы */}        setCurrentStep(getPreviousStep(currentStep))}         isValid={!data.validationErrors?.length}       /&gt;     </div>   ); }; 

И наконец-то раздельное тестирование компонент. Здесь пройдемся по коду подробней.

// components/UserForm/UserForm.test.tsx // Тесты компонента формы UserForm  import { render, screen, waitFor, fireEvent } from '@testing-library/react'; // Импортируем необходимые функции из библиотеки тестирования import { UserForm } from './UserForm'; // Импортируем тестируемый компонент import { UserFormApiClient } from '../../api/userFormApi'; // Импортируем API клиент  // Мокируем API клиент UserFormApiClient.  // Это необходимо для изоляции компонента UserForm от реальных запросов к API. // Jest заменит реальный UserFormApiClient на мок-реализацию. jest.mock('../../api/userFormApi');  describe('UserForm', () => { // Описываем набор тестов для компонента UserForm   // Функция, которая выполняется перед каждым тестом (beforeEach).   // Здесь мы настраиваем мок-реализацию API клиента.   beforeEach(() => {     // mockImplementation используется для создания мок-функций.     (UserFormApiClient as jest.Mock).mockImplementation(() => ({       // Мокируем метод getFormData. Он будет возвращать промис, который резолвится с моковыми данными.       getFormData: jest.fn().mockResolvedValue({         id: 'test',         step: 'personal',         data: {},         status: 'draft'       }),       // Мокируем метод saveFormData. Он также будет возвращать промис, который резолвится с моковыми данными.       // Важно, что данные, переданные в saveFormData, будут возвращены обратно, имитируя сохранение.       saveFormData: jest.fn().mockImplementation(async (id, data) => ({         id,         ...data, // Распространяем переданные данные в моковый ответ         status: 'draft'       }))     }));   });    // Тест: проверка начального состояния загрузки (отображение спиннера).   it('shows loading state initially', () => {     render(<UserForm formId="test"/>); // Рендерим компонент UserForm. formId обязательный пропс.     expect(screen.getByTestId('spinner')).toBeInTheDocument(); // Проверяем, что на экране отображается элемент со атрибутом data-testid="spinner" (спиннер).   });    // Тест: проверка навигации по форме.   it('handles form navigation', async () => {     render(<UserForm formId="test"/>); // Рендерим компонент      // Ждем, пока компонент загрузится и на экране появится элемент с data-testid="personal-step".     // waitFor используется для ожидания асинхронных операций.     await waitFor(() => {       expect(screen.getByTestId('personal-step')).toBeInTheDocument(); // Проверяем, что отобразился шаг "personal"     });      // Эмулируем ввод данных в поле с data-testid="name-input".     fireEvent.change(screen.getByTestId('name-input'), {       target: { value: 'John' } // Вводим значение "John"     });     // Эмулируем клик по кнопке "Next".     fireEvent.click(screen.getByText('Next'));      // Ожидаем, пока произойдет переход на следующий шаг (появление элемента с data-testid="address-step").     await waitFor(() => {       expect(screen.getByTestId('address-step')).toBeInTheDocument(); // Проверяем, что отобразился шаг "address"     });   }); });

вот! мы и добрались до преимуществ компонентного подхода:

Преимущества предлагаемой архитектуры:

  • Разделение ответственности (SoC): Каждый модуль отвечает за свою область, что упрощает разработку и поддержку.

  • Тестируемость: Легко проводить Unit-тестирование компонентов и API.

  • Масштабируемость: Возможность расширения функциональности и добавления новых модулей.

  • Переиспользование кода: Общие типы и константы используются как на Frontend, так и на Backend.

  • Параллельная разработка: Frontend и Backend могут разрабатываться независимо.


ссылка на оригинал статьи https://habr.com/ru/articles/868244/