Продолжение статьи о разработке B2B платформы международной торговли
Вступление
Прошло некоторое время с момента публикации первой части, где мы рассказали о концепции и начальном этапе разработки LiqTrade. За это время проект прошел путь от MVP до полноценной production-ready платформы.
В этой статье я хочу поделиться реальными проблемами, с которыми мы столкнулись, и способами их решения. Никакого идеализированного "как всё прекрасно работает" - только честный разбор косяков, рефакторинга и технического долга.
Дисклеймер: Если вы ищете статью в стиле "10 строк кода, и ваш стартап взлетел" - это не здесь. Здесь будет про то, как мы 3 раза переписывали систему ролей, боролись с circular dependencies и почему наш bundle вырос до 1.2 ГБ (спойлер: мы его победили).
? Что было сделано: Цифры и факты
Структура проекта на сегодня:
259 TypeScript/React файлов
40 JSON файлов переводов (5 языков)
9 Supabase Edge Functions
87+ тестов (Unit + E2E)
~35,000 строк кода (frontend + backend + docs)
Основные модули:
✅ Аутентификация и авторизация (JWT + MFA ready)
✅ RBAC система (27 ролей, 3 уровня)
✅ RFQ Management (создание, редактирование, удаление)
✅ Offers & Deals pipeline
✅ Документооборот и KYC/KYB
✅ Калькулятор таможенных платежей
✅ Интернационализация (RU, EN, AR, ZH, TR)
✅ File Upload система
✅ Real-time notifications (готово к интеграции)
? Проблема #1: Circular Dependencies в RBAC системе
Как мы наступили на грабли
Изначально RBAC был реализован в одном файле src/config/rbac-policies.ts на 800+ строк. Всё работало... пока мы не решили добавить динамические политики.
// Было (ПЛОХО):
// src/config/rbac-policies.ts
export const policies = { ... }
export const useRBAC = () => { ... } // ❌ хук в конфиге
// src/hooks/useRBAC.ts
import { policies } from '@/config/rbac-policies' // ❌ импорт конфига
// src/config/rbac-policies.ts снова импортирует useRBAC
import { useRBAC } from '@/hooks/useRBAC' // ? CIRCULAR!
Результат: Build падал с ошибкой ReferenceError: Cannot access 'X' before initialization.
Как мы это исправили
Шаг 1: Разделили по принципу Single Responsibility
src/lib/rbac/
├── types.ts # Только типы, без логики
├── core.ts # Pure functions без импортов
├── policies/
│ ├── index.ts # Объединение всех политик
│ ├── rfq.ts # RFQ-специфичные политики
│ ├── deals.ts # Deals-специфичные политики
│ └── users.ts # User management политики
└── index.ts # Единая точка входа (exports only)
Шаг 2: Применили Dependency Injection
// ✅ core.ts - pure functions без внешних зависимостей
export function checkAccess(
policies: Policy[], // ✅ передаём через параметры
context: PolicyContext,
resource: Resource,
action: Action
): AccessCheck {
// Чистая логика без импортов
const applicable = policies.filter(p =>
p.roles.includes(context.userRole) &&
p.resources.includes(resource) &&
p.actions.includes(action)
)
return { granted: applicable.length > 0 }
}
// ✅ Hook использует core functions
export function useRBAC() {
const { user } = useAuth()
const context: PolicyContext = useMemo(() => ({
userId: user?.id || '',
userRole: user?.role as UserRole,
companyId: user?.companyId,
}), [user])
// Импортируем политики только здесь
return {
checkAccess: (resource, action) =>
checkAccess(allPolicies, context, resource, action)
}
}
Результат: Build время сократилось с 45 секунд до 12 секунд, circular dependencies устранены.
Lesson learned:
Если видите circular dependency — это не проблема импортов, это проблема архитектуры. Разделяйте типы, логику и зависимости.
? Проблема #2: TypeScript strict mode был отключён
Почему мы это сделали (и почему это было ошибкой)
На начальном этапе мы отключили строгие проверки TypeScript, чтобы "двигаться быстрее":
// tsconfig.json (ПЛОХАЯ версия)
{
"compilerOptions": {
"noImplicitAny": false, // ❌ любой any без предупреждений
"strictNullChecks": false, // ❌ игнор null/undefined
"noUnusedLocals": false // ❌ неиспользуемый код не детектится
}
}
Последствия:
82 файла содержали
anyтипы143 случая использования
console.log(утечки данных)Bugs в runtime, которые TypeScript мог бы поймать
Реальный баг, который мы словили
// Баг в AuthContext
const loadUser = async () => {
const user = await apiClient.getCurrentUser() // может вернуть null
setUser(user)
// ? BOOM! Cannot read property 'email' of null
toast.success(`Welcome ${user.email}`)
}
С включённым strictNullChecks TypeScript показал бы ошибку:
// ✅ С strict mode
const user = await apiClient.getCurrentUser() // User | null
// ❌ Error: Object is possibly 'null'
toast.success(`Welcome ${user.email}`)
// ✅ Правильная версия
if (user) {
toast.success(`Welcome ${user.email}`)
}
Как мы мигрировали на strict mode
Стратегия: Постепенное включение (файл за файлом)
# 1. Создали отдельную ветку
git checkout -b feature/strict-typescript
# 2. Включили strict для одного модуля
# tsconfig.json
{
"include": ["src/lib/rbac/**/*"], // только RBAC сначала
"compilerOptions": {
"strict": true
}
}
# 3. Исправили все ошибки в RBAC (2 дня)
# 4. Повторили для остальных модулей
По модулям:
Неделя 1:
src/lib/**/*(утилиты, API клиент)Неделя 2:
src/hooks/**/*(все hooks)Неделя 3:
src/components/**/*(UI компоненты)Неделя 4:
src/pages/**/*(страницы)
Автоматизация: Скрипт для замены any на unknown
// scripts/fix-any-types.ts
import * as ts from 'typescript'
import * as fs from 'fs'
function replaceAnyWithUnknown(sourceFile: ts.SourceFile): string {
let result = sourceFile.getFullText()
ts.forEachChild(sourceFile, function visit(node) {
if (ts.isTypeReferenceNode(node) &&
node.typeName.getText() === 'any') {
result = result.replace(': any', ': unknown')
}
ts.forEachChild(node, visit)
})
return result
}
// Применили ко всем файлам
const files = glob.sync('src/**/*.ts')
files.forEach(file => {
const content = fs.readFileSync(file, 'utf8')
const sourceFile = ts.createSourceFile(file, content, ts.ScriptTarget.Latest)
const fixed = replaceAnyWithUnknown(sourceFile)
fs.writeFileSync(file, fixed)
})
Результат:
0 использований
anyв production коде47 багов обнаружено и исправлено до production
Время разработки увеличилось на ~15%, но количество багов упало на ~60%
Lesson learned:
Strict mode должен быть включён с первого дня. "Мы включим позже" = никогда не включим.
? Проблема #3: Bundle size вырос до 1.2 Мб
Как это произошло
После добавления всех зависимостей наш npm run build начал выдавать:
dist/assets/vendor-abc123.js 847.3 KB ⚠️
dist/assets/ui-radix-def456.js 234.1 KB ⚠️
dist/assets/index-ghi789.js 156.8 KB ⚠️
Total bundle size: 1.24 Mb ⚠️⚠️⚠️
Lighthouse Score упал с 95 до 23.
Что было не так
Все иконки Lucide-react импортировались целиком
// ❌ ПЛОХО - импорт всей библиотеки (200+ KB)
import * as Icons from 'lucide-react'
const Icon = Icons[iconName]
shadcn/ui компоненты дублировались
// 15 файлов импортировали одно и то же
import { Button } from '@/components/ui/button' // дублируется 15 раз
React Query DevTools в production
// ❌ в production сборке!
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
Все страницы загружались сразу
// ❌ Без lazy loading
import Dashboard from './pages/Dashboard'
import RFQPage from './pages/RFQPage'
// ... ещё 10 страниц
Как мы оптимизировали
1. Tree-shaking для иконок
// ✅ ХОРОШО - импорт только нужных иконок
import { Search, Plus, X, ChevronDown } from 'lucide-react'
// Результат: 200 KB → 12 KB
2. Настроили manualChunks в Vite
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// Core React (один раз на всё приложение)
vendor: ['react', 'react-dom', 'react-router-dom'],
// UI библиотека
'ui-radix': [
'@radix-ui/react-dialog',
'@radix-ui/react-dropdown-menu',
// ... только часто используемые
],
// Data layer
'data-layer': ['@tanstack/react-query', '@supabase/supabase-js'],
// Редко используемые страницы
'admin-pages': [
'./src/pages/admin/AdminPanel',
'./src/pages/admin/UserManagement'
]
}
}
}
}
})
3. Lazy loading для всех routes
// src/App.tsx
import { lazy, Suspense } from 'react'
// ✅ Lazy load
const Dashboard = lazy(() => import('./pages/Dashboard'))
const RFQPage = lazy(() => import('./pages/RFQPage'))
const AdminPanel = lazy(() => import('./pages/admin/AdminPanel'))
function App() {
return (
<Suspense fallback={<Loader />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/rfq" element={<RFQPage />} />
<Route path="/admin" element={<AdminPanel />} />
</Routes>
</Suspense>
)
}
4. Dynamic imports для тяжёлых библиотек
// ✅ Загружаем recharts только когда нужно
async function loadChartComponent() {
const { LineChart } = await import('recharts')
return LineChart
}
// В компоненте
const [ChartComponent, setChartComponent] = useState(null)
useEffect(() => {
loadChartComponent().then(setChartComponent)
}, [])
5. Code splitting по роутам
// vite.config.ts
manualChunks: (id) => {
// Dashboard в отдельный chunk
if (id.includes('pages/Dashboard')) {
return 'route-dashboard'
}
// Admin в отдельный chunk (редко используется)
if (id.includes('pages/admin')) {
return 'route-admin'
}
// RFQ + Offers + Deals вместе (часто переключаются)
if (id.includes('pages/RFQ') ||
id.includes('pages/Offers') ||
id.includes('pages/Deals')) {
return 'route-trading'
}
}
Результаты оптимизации:
# ДО:
Total bundle size: 1.24 GB
Initial load: 847 KB
FCP: 4.2s
Lighthouse: 23
# ПОСЛЕ:
Total bundle size: 287 KB (↓ 77%)
Initial load: 156 KB (↓ 82%)
FCP: 1.1s (↓ 74%)
Lighthouse: 94 (↑ 309%)
Lesson learned:
Не добавляйте библиотеки "на всякий случай". Каждый KB имеет значение. Measure first, optimize second.
? Проблема #4: i18n — от "потом сделаем" до production
Почему мы откладывали
На старте проекта мы решили: "Сделаем английский, остальные языки потом". Классическая ошибка.
Что пошло не так:
Хардкод строк везде в коде
Дата форматы "на глаз"
Валюты без локализации
RTL (арабский) вообще не учитывался
Когда пришло время добавлять языки, выяснилось что нужно переписать 70% компонентов.
Как мы внедрили i18n правильно
Шаг 1: Выбрали i18next + react-i18next
npm install i18next react-i18next i18next-browser-languagedetector i18next-http-backend
Шаг 2: Структура переводов по модулям
public/locales/
├── ru/
│ ├── common.json # Общие: кнопки, навигация, статусы
│ ├── dashboard.json # Специфичные для дашборда
│ ├── rfq.json # RFQ модуль
│ ├── auth.json # Аутентификация
│ └── ...
├── en/ (то же самое)
├── ar/ (العربية - with RTL)
├── zh/ (中文)
└── tr/ (Türkçe)
Шаг 3: Конфигурация с namespace support
// src/lib/i18n.ts
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import HttpBackend from 'i18next-http-backend'
i18n
.use(HttpBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'ru',
supportedLngs: ['ru', 'en', 'ar', 'zh', 'tr'],
// ✅ Namespaces для модульности
ns: ['common', 'dashboard', 'rfq', 'offers', 'deals', 'auth'],
defaultNS: 'common',
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
detection: {
order: ['localStorage', 'navigator', 'htmlTag'],
caches: ['localStorage'],
},
})
Шаг 4: Рефакторинг компонентов
// ❌ БЫЛО (хардкод)
function Dashboard() {
return (
<div>
<h1>Дашборд</h1>
<button>Создать запрос</button>
<p>Активных запросов: {count}</p>
</div>
)
}
// ✅ СТАЛО (с переводами)
function Dashboard() {
const { t } = useTranslation('dashboard')
return (
<div>
<h1>{t('title')}</h1>
<button>{t('actions.createRequest')}</button>
<p>{t('stats.activeRequests', { count })}</p>
</div>
)
}
Шаг 5: RTL support для арабского
// src/lib/i18n.ts
export const languages = {
ar: {
code: 'ar',
name: 'العربية',
dir: 'rtl', // ✅ Right-to-left
},
// ...
}
// При переключении языка
i18n.changeLanguage(lng)
document.documentElement.dir = languages[lng].dir
document.documentElement.lang = lng
/* src/index.css */
[dir="rtl"] {
direction: rtl;
text-align: right;
}
/* Flexbox автоматически реверсится */
[dir="rtl"] .flex-row {
flex-direction: row-reverse;
}
Шаг 6: Переводы через AI (ChatGPT)
// Промпт для ChatGPT:
"Переведи JSON объект с английского на арабский, китайский и турецкий.
Сохрани структуру и ключи, переведи только значения.
Используй формальный стиль для business контекста."
// Input:
{
"common": {
"actions": {
"create": "Create",
"save": "Save"
}
}
}
// Output для ar:
{
"common": {
"actions": {
"create": "إنشاء",
"save": "حفظ"
}
}
}
Результаты:
40 JSON файлов переводов (5 языков × 8 модулей)
100% покрытие UI строк
RTL support для арабского рынка
Время на добавление нового языка: ~2 часа (было бы ~2 недели без i18n)
Lesson learned:
i18n нужно добавлять до написания первого компонента, не после. Рефакторинг 70% кода — это боль.
? Проблема #5: Edge Functions — когда Supabase не хватает
Зачем нам понадобились Edge Functions
Изначально мы делали всё через Supabase Database Functions (PL/pgSQL):
-- Создание RFQ через SQL функцию
CREATE FUNCTION create_rfq(p_user_id UUID, p_data JSONB)
RETURNS UUID AS $$
DECLARE
v_rfq_id UUID;
BEGIN
INSERT INTO rfqs (user_id, title, description, ...)
VALUES (p_user_id, p_data->>'title', ...)
RETURNING id INTO v_rfq_id;
RETURN v_rfq_id;
END;
$$ LANGUAGE plpgsql;
Проблемы:
Сложная бизнес-логика не подходит для SQL
Нет типизации - ошибки только в runtime
Тестирование - кошмар с SQL функциями
Интеграции (email, webhooks) из SQL невозможны
Переход на Edge Functions
Структура:
supabase/functions/
├── create-rfq/
│ └── index.ts # TypeScript!
├── verify-kyc/
│ └── index.ts
├── send-notification/
│ └── index.ts
└── deno.json # Конфигурация
Пример: create-rfq/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
serve(async (req) => {
// CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
// Аутентификация
const supabaseClient = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{
global: {
headers: { Authorization: req.headers.get('Authorization')! }
}
}
)
const { data: { user } } = await supabaseClient.auth.getUser()
if (!user) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }),
{ status: 401 }
)
}
// Валидация
const rfqData = await req.json()
if (!rfqData.title || !rfqData.description) {
return new Response(
JSON.stringify({ error: 'Missing required fields' }),
{ status: 400 }
)
}
// Создание RFQ
const { data, error } = await supabaseClient
.from('rfqs')
.insert({
user_id: user.id,
title: rfqData.title,
description: rfqData.description,
status: 'draft',
created_at: new Date().toISOString()
})
.select()
.single()
if (error) throw error
// ✅ Отправляем email уведомление
await sendNotificationEmail(user.email, 'RFQ Created', data)
// ✅ Webhook для интеграций
await triggerWebhook('rfq.created', data)
return new Response(
JSON.stringify(data),
{ headers: { 'Content-Type': 'application/json' } }
)
} catch (error) {
console.error('Error:', error)
return new Response(
JSON.stringify({ error: error.message }),
{ status: 500 }
)
}
})
Deployment:
# Разворачиваем все функции
supabase functions deploy
# Или одну конкретную
supabase functions deploy create-rfq
# Проверяем логи
supabase functions logs create-rfq --tail
Преимущества Edge Functions:
✅ TypeScript с полной типизацией
✅ npm пакеты через ESM
✅ Легко тестировать (обычный HTTP endpoint)
✅ Быстрое выполнение (Deno runtime)
✅ Автоматический scaling
Результаты:
Миграция 15 SQL функций → 9 Edge Functions
Время разработки новой функции: ~2 часа (было ~1 день с SQL)
0 SQL ошибок в production (были 7 за месяц)
Lesson learned:
Используйте SQL для данных, TypeScript для бизнес-логики. Edge Functions — это именно то, что нужно для сложных операций.
? Что дальше: Roadmap на Q1 2026
В работе (ближайший месяц)
1. Real-time notifications через WebSocket
// Supabase Realtime
const channel = supabase
.channel('rfq-updates')
.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'rfqs' },
(payload) => {
toast.info(`Новый RFQ: ${payload.new.title}`)
}
)
.subscribe()
2. Stripe integration для подписок
Free: 5 RFQ/месяц
Starter ($49): 50 RFQ/месяц
Pro ($149): 200 RFQ/месяц
Enterprise ($499): Unlimited
3. Audit logging для compliance
CREATE TABLE audit_logs (
id UUID PRIMARY KEY,
user_id UUID,
action VARCHAR(50),
resource_type VARCHAR(50),
resource_id UUID,
changes JSONB,
ip_address INET,
timestamp TIMESTAMPTZ DEFAULT NOW()
);
4. Advanced search через Typesense
// Полнотекстовый поиск с фильтрами
const results = await typesenseClient
.collections('rfqs')
.documents()
.search({
q: 'steel coils',
query_by: 'title,description',
filter_by: 'status:active && price:<100000',
sort_by: 'created_at:desc'
})
Проект, который я задумывал как пет проект постепенно перерастает в ад :)
Комментарии (5)

Amonoc Автор
15.10.2025 00:39Это не основная функция платформы, а один их функционалов. Задача этой функции простая: аккумулировать заявки, чем больше сумма, тем меньше комиссия. У нас довольно большое количество экспортеров, поэтому агргегация будет возможна. Для этой функции есть платежные агенты, которые могут собирать заявки внутри и за, чтобы потом распределять денежные потоки на основании инвойсов и заявок.
Ну тут соглашусь - очепятка)
Это мой третий проект с нуля, и каждый раз проекты сложнее и геморра с ними больше и больше)
dyadyaSerezha
1) Все же кажется, что ахилесова пята - это наличие встречных заявок для взаиморасчетов. Если между странами А и В торговля не полностью равная в обоих направлениях, то всегда будут оставаться невыполненные заявки, которые сначала потеряют время (чтобы выяснить, что для них нет встречной заявки), а потом и деньги (когда хозяева переключат их на классический SWIFT-перевод). В идеале это был бы собственный расчётный орган/банк с представительствами во всех странах и сам делающий псевдопереводы денег (через крипту, например). Но понимаю, что это совсем другой уровень и риски.
2) Не понял про размер пакетов. Сначала везде KB, а потом вдруг сразу GB. А куда делись MB?
3) welcome to hell)