zero deps
57 subscribers
1 photo
27 links
Node.js, V8, highload, архитектура.
Где производительность, а где оверинжиниринг?

Код, профилирование и воспроизводимые бенчмарки.

Всё, что ставим — потом и поддерживаем.
Фиксим то, что сами и сломали.

zerodeps.tech
Download Telegram
1. TypeScript не гарантирует надёжность

TypeScript часто подают как инструмент «защиты от ошибок».
Но это не защита. Это компилятор, который верит тебе на слово.

Он проверяет типы только в пределах того, что ты сам описал.

Он не валидирует входные данные. Не ловит runtime-ошибки. Не гарантирует корректность логики.

Надежность типизации ограничена:
• если API возвращает не то — компилятор не узнает;
• если ты неверно понял схему — типы будут ложными;
• если прилетели некорректные данные — баг всё равно произойдёт;
• если хочешь обойти систему — as unknown as, @ts-expect-error, any и поехали.

TypeScript не мешает ошибаться — он просто делает это чище.

Пример:
//Описал тип
type User = { id: string, email: string }

//Ответ пришел
// { "id": 42, "email": null }

// Используешь тип
const user = response as User

// И компилятор тебе поверит.
// До первого user.email.toLowerCase()


Если не валидировать вход — получаешь баг на ровном месте.

А дальше выбор:
• тянуть рантайм-библиотеку (zod, io-ts);
• писать собственный инструмент для проверки;
• или руками валидировать каждый объект.

Возникает вопрос: какой ценой мы получаем мнимое ощущение безопасности при возможном несоответствии типов?

TypeScript — это не про надежность и «безопасность».

Это про удобство разработки внутри доверенного кода.

Без валидации на границах системы типы ничего не гарантируют.
В системе безопасность начинается с входных данных.
Типы — уже потом.


Другие части:

0. Что даёт TypeScript, кроме типов
👍31🔥1
2. TypeScript ломает zero-deps и замедляет цикл разработки

Компиляция — это цена. Даже если ты её не замечаешь.

Для запуска TS-кода тебе нужно:
- tsconfig.json;
- транспиляция (tsc, swc, esbuild);
- типы зависимостей (@types/...);
- поддержка ESM или CommonJS;
- линтер с типами;
- конфиг для тестов, алиасов, runtime-импорта.

Каждый шаг — точка отказа. Любое несовпадение версий, любые нестыковки — и пайплайн падает.

Zero-deps становится невозможен: язык требует инфраструктуру.

Даже с Node.js v23.6.0, где TypeScript встроен на уровне рантайма, остаётся всё то же: конфигурация, типы, source maps, IDE-интеграция.

Это не «убрали сборку». Это встроили другой сборщик. Бремя осталось.

Чистый JS запускается сразу.
TS — только после обвязки.


Дольше цикл разработки


TypeScript ломает быстрый цикл:

написал → запустил → исправил → повторил.

Становится:
написал → собрал → подправил конфиг → проверил типы → запустил → проверил пути → дебажил через мапы → исправил → пересобрал.

Каждый шаг замедляет итерации. Особенно в системных проектах, где:
- код быстро меняется;
- архитектура нестабильна;
- важна предсказуемость запуска.

Ухудшение DX

Developer Experience — это не «удобно писать код в IDE».
Это: быстро читать, быстро запускать, быстро менять.

С TS:
- IDE может не видеть типы до полной сборки;
- tsc может упасть из-за файла, к которому ты не прикасался;
- линтеры требуют доп. плагинов и синхронизации с tsconfig;
- ошибки появляются там, где их нет на runtime.

TS вставляет прослойку между мыслью и результатом.
JS — работает напрямую.

Отладка — через боль


TypeScript ломает прозрачность рантайма:
- source maps не всегда точные;
- стеки уводят в dist/, не в исходник;
- console.log работает не там, где ты пишешь;
- hot reload требует костылей;
- для запуска кода из ./src тебе нужно убедиться, что ./dist актуален.

Тормозит командную работ

Любой merge может:
- поломать сборку;
- привести к конфликту типов;
- зацепить @types/..., которые подтянулись транзитивно;
- потребовать пересогласования alias.

Проект перестаёт быть “просто нодой”.
Он превращается в конвейер, который надо всё время чинить

Что в итоге?

TypeScript даёт мнимую стабильность на длинном горизонте.
Но за это ты платишь: скоростью, простотой, прозрачностью.

Если ты пишешь system-level tooling, оркестратор, сервер или CLI — TypeScript может быть не помощником, а помехой.

Чем ближе к ядру — тем важнее zero-deps.
И тем дороже каждая секунда, потерянная на сборку.

Другие части:

0. Что даёт TypeScript, кроме типов
1. TypeScript не гарантирует надёжность
3👍2
Когда абстракция начинает мешать

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

Как это заметить:
- Чтобы изменить одну строчку логики, ты бродишь по трём-четырём слоям кода.
- Больше времени уходит на понимание связей, чем на саму задачу.
- В тестах приходится подменять половину проекта моками, потому что абстракции закрыли доступ к данным.
- Новому разработчику сложно объяснить архитектуру без многоуровневой диаграммы.

Абстракция — это контракт.

Это упрощённое представление сложной системы, которое скрывает детали реализации и оставляет только то, что нужно для использования.  
Хорошая абстракция помогает работать с кодом на более высоком уровне, не вникая каждый раз в низкоуровневую реализацию.

Она полезна, пока:
1. Снижает когнитивную нагрузку.
2. Не мешает отладке.
3. Не ломает принцип локальности изменений (когда правка затрагивает минимум мест в коде).

Когда абстракция нарушает хотя бы два пункта, она превращается в технический долг.

Чеклист: вредная абстракция

Если хотя бы 3 ответа «да» — пора упрощать:
-  Описание абстракции занимает больше одного предложения и включает «и ещё…»?
-  Для отладки нужно читать её внутренний код?
-  Неясно, где вход и где выход данных?
-  Изменение бизнес-логики затрагивает больше трёх файлов?
-  Для тестирования требуются сложные моки или фейки?
-  Новому разработчику нужно больше часа, чтобы понять, как её использовать?

Пример 1:
Вместо прямого вызова db.query(...) вы сделали три уровня: ORM + репозиторий + сервис. На старте это казалось «правильной архитектурой». Через год — для добавления нового поля в запрос вам приходится лезть в мапперы, DTO, конвертеры и тесты для каждой прослойки. Любая правка тянет за собой десяток файлов.

Пример 2:
Вместо простого HTTP-клиента для внешнего API сделали обёртку с «универсальным» интерфейсом, планируя поддержку разных провайдеров. Но новых провайдеров так и не появилось, а теперь при отладке запроса нужно разбираться в слоях адаптеров, трансформеров и middleware, которые в итоге лишь дублируют логику оригинального SDK.

Что в итоге?
Если абстракция усложняет отладку, замедляет изменения и заставляет править много несвязанных мест, её нужно упростить или удалить.

Хорошая абстракция ускоряет работу, плохая — тормозит.


Еще интересное про абстракции:
Закон дырявых абстракций - почему абстракции «протекают» и когда это важно.

Sebastian Markbåge “Minimal API Surface Area” - как лишние фичи мешают эволюции кода

Sandi Metz The Wrong Abstraction - как распознать «не ту» абстракцию и безопасно её распрямить.
3👍2🔥2
3. Когда TypeScript оправдан

TypeScript — не серебряная пуля. Это инструмент управления сложностью, а не способ «починить» слабую архитектуру. Он помогает согласовывать контракты и делает рефакторинг предсказуемым, но не заменяет системный дизайн.

Когда TS имеет смысл:

- Длинная жизнь продукта и кодовая база, которая растёт годами.
- Несколько команд, параллельные изменения, обилие внутренних и внешних API.
- Есть публичные SDK/клиенты, где обратная совместимость — не пожелание, а требование.
- Стоимость рантайм-ошибки высока: деньги, SLA, безопасность.

Если проект маленький, недолговечный, с узкими интерфейсами — издержки типизации часто превышают пользу.

Что даёт TS:

- Контракты на границах модулей. Явные типы для DTO, событий, адаптеров. Ломаете поле — компилятор показывает фронт разрушений.
- Дешёвый рефакторинг. Переименование, выделение интерфейсов, смена сигнатур — IDE и компилятор ведут тебя за руку.
- Локализация неопределённости. unknown/never/asserts подчёркивают места, где риск и где нужны проверки.
- Документация, которая не врёт. Типы — это живой, проверяемый артефакт, а не устаревающий README.

Чего TS не даёт:

- Архитектуры. Границы контекстов, потоки данных, изоляция побочных эффектов — это решения уровня дизайна.
- Согласованности данных и эволюции схем. Миграции, инварианты и порядок обновления сервисов — за вами.
- Надёжности. Повторные попытки, идемпотентность, дедупликация событий — это протоколы и инфраструктура.
- Производительности. Типы не ускоряют обработку запросов и не уменьшают GC-паузы.

Формула простая: TS снижает стоимость изменения, но не подменяет мышцу архитектурного мышления.

Пример:

Рефакторинг поля total в amount в DTO между сервисами orders и billing:

С TS практически сразу будет обозначен фронт проблем во всех местах, где читали total, включая сериализацию, агрегации и тесты. Без TS часть мест «проскочит», всплывёт в проде при редком кейсе.

Контрпример:

В одном модуле сетевая логика, доменная модель и кэш. TS это оттипизирует, но не разрежет связность. По-прежнему сложно независимо тестировать и разворачивать части. TS помогает согласовать и облегчить рефакторинг, но не исправит архитектуру.

Что в итоге?

Используй TS там, где много кода, много людей и много интерфейсов. Там он окупается: контракты формализованы, рефакторинг дешевле, регрессов меньше. Не жди, что TS решит проблемы дизайна — слабые границы, излишнюю связность и плохие протоколы он не лечит.

Если проект небольшой — строгая дисциплина модулей и простые соглашения дадут такой же эффект с меньшими накладными расходами.

Другие части:

0. Что даёт TypeScript, кроме типов
1. TypeScript не гарантирует надёжность
2. TypeScript ломает zero-deps и замедляет цикл разработки
3👍2
Визуализация архитектуры через C4 Model

C4 — модель визуализации архитектуры, предложенная Саймоном Брауном.
Её задача — дать единый способ описания системы на разных уровнях детализации, так чтобы и бизнес, и разработчики, и эксплуатация видели одну и ту же архитектуру, но с нужной им степенью глубины.

Вместо одной перегруженной диаграммы C4 предлагает четыре отдельных представления:

1. Context
Показывает систему в окружении: кто её использует, какие внешние сервисы есть, как идёт взаимодействие. Это уровень, на котором важны роли пользователей, интеграции и точки входа.

2. Container
Разделение системы на крупные исполняемые блоки: сервисы, базы данных, мобильные приложения, очереди, файлохранилища. Здесь фиксируются протоколы, точки развёртывания, границы отказоустойчивости.

3. Component
Внутреннее устройство контейнера: ключевые модули, их обязанности и связи. Это взгляд разработчика на систему, когда важно понять, в каком модуле живёт логика и как она связана с другими частями.

4. Code
Детализация до уровня классов и функций. В отличие от UML, в C4 этот уровень опционален — его используют только там, где реально полезно держать карту реализации.

Чем полезна модель:
- Делит архитектурное описание на понятные и управляемые слои.
- Даёт общий язык для обсуждения — от бизнеса до инженеров.
- Упрощает актуализацию документации: можно обновить один уровень, не переписывая всё.
- Работает одинаково для монолитов, распределённых систем и микросервисов.

Что важно понимать:
- C4 — это формат описания, а не метод проектирования. Он не подскажет, как правильно разделить сервисы или где ставить границы.
- Модель фиксирует уже принятые решения. Если решение плохое, на диаграмме оно просто будет аккуратно нарисовано.
- Она хорошо работает только при регулярном обновлении. Устаревшая диаграмма хуже, чем её отсутствие.


Пример применения:
Для новой системы можно сделать Context-диаграмму для стейкхолдеров, Container-уровень для DevOps и команды разработки, а Component-уровень — для бэкендеров, которые будут поддерживать сервис.
При этом все уровни связаны: элемент на Context-уровне соответствует контейнеру на следующем, а контейнер — набору компонентов внутри.


Полезное:
https://c4model.com/
👍53
C4 Model и "уровни" архитектуры

В прошлом посте я рассказал про C4 модель Саймона Брауна. Модель помогает визуализировать архитектуру так, чтобы и бизнес, и инженеры, и эксплуатация видели одну и ту же картину, но с нужной детализацией.

А так же я писал про то Где начинает архитектура - от переменной до экосистемы.

Если присмотреться то, можно заметить, что модель C4 довольно неплохо ложиться на эти уровни.

Давай посмотрим:

Context - Уровень 5
Диаграмма контекста в C4 — это взгляд на систему в окружении: пользователи, внешние сервисы, интеграции. Это соответствует экосистемному уровню: кто с кем говорит, какие протоколы, какие точки отказа.

Container - Уровень 4
Контейнеры — это крупные исполняемые части: сервисы, базы, очереди, фронтенды. Здесь важны вопросы развертывания, деплоя, масштабирования, согласованности и отказоустойчивости. Это соответствует архитектуре приложения: entry point, разделение core-логики и инфраструктуры, сценарии деградации.

Component - Уровень 3
Компоненты — модули внутри контейнера. Это уже граница между архитектурой и проектированием модулей: что модуль знает о внешнем мире, где side-effect, где бизнесс логика.

Code - Уровень 2–1
C4 опционально доходит до уровня классов и функций, но это уже зона проектирования кода. Тут работает дисциплина имен, SRP, локализация ответственности — то, что в описании на уровне переменной и функции.

Что в итоге?

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

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

Полезное:
Simon Brown — Visualising Software Architecture
Architectural boundaries
👍42
Зачем ты везде тащишь очереди?

Вот ты проектируешь свою систему, базы, микросервисы, кэш - все красиво. И вот встает вопрос: как это все связать, как они будет общаться?

И тут со всех утюгов кричат:
- У тебя микросервисы - тебе нужны очереди!
- Очереди - это модно.
- Очереди - это полезно.
- Очереди - это масштаб и надежность.
- Очереди "корпоративный стандарт". (как и TS, ага да)

Ты берешь, допустим RabitMQ, поднимаешь брокер (+1 сервис и глобальная точка отказа), и получаешь большую асинхронную боль сквозь всю систему, рост когнитивной нагрузки и эксплуатационные издержки: лаг, дубликаты, краш консумера, ядовитые сообщения и дрейф схем. Да есть и полезное: буферизация, распределение по времени, изоляция сбоев и фан-аут. Но какой ценой?

Если у тебя CRUD между двумя сервисами и одинаковые требования по времени ответа и доступности - очередь только навредит.

Синхронный вызов честно говорит: «либо сделал, либо нет».
С очередью ты говоришь: «когда-нибудь, возможно, кто-то где-то это обработает».

Отсюда вылезают взрослые темы, которые почему-то игнорируют, когда «прикручивают Kafka/Rabbit ради моды»:
- Доставка: at-least-once - дубликаты - идемпотентность на приёмнике
- Порядок: забудь «строгий порядок», у тебя head-of-line blocking или вообще все перемешано.
- Нагрузка: «очередь поможет удержать пик нагрузки» — да, но хвост растёт. Хвост — это твой скрытый долг по времени отклика.
- Мониторинг: трассировка через брокер — это чёрный ящик и пляски с кореляторами.
- Контракты: cобытие — это публичное API. Версионирование, эволюция схем, миграции потребителей — отдельная боль.

Очередь приносит тебе асинхронность как обязательство. А это обязательство принуждает тебя проектировать идемпотентные операции, хранить входящие/исходящие события, жанглировать ретраями и дедупликацией, думать про повторный прогон истории (пересчёт из журнала). Если ты к этому не готов - будет больно, очередь сломает прод, или тебя.
🔥3
Пример

Сценарий: Создать задачу и отправить ее на исполнение в другой сервис.

Вариант А — MongoDB + outbox (без внешней очереди):

Идея простая: Одной транзакцией пишем задачу и событие. Сервис исполнитель читает события напрямую, делает работу, фиксирует факт обработки. Никакой магии, никаких скрытых механизов доставки, честная атомарность.

javascript
// Сервис "Планирователь"
/**
* @param {import('mongodb').MongoClient} client
* @param {import('mongodb').Db} db
* @param {Task} task
* @returns {{status: boolean, taskId: string}}
*/
export async function createTask (client, db, task) {
const session = client.startSession()

try {
const eventId = crypto.randomUUID()

session.startTransaction()

await db.collection('tasks').insertOne(
{
_id: task.id,
type: task.type,
payload: task.payload,
status: 'pending',
createdAt: Date.now()
},
{ session }
)

await db.collection('outbox').insertOne(
{
_id: eventId,
type: 'task.created',
payload: { taskId: task.id },
createdAt: Date.now(),
pickedAt: null,
doneAt: null
},
{ session }
)

await session.commitTransaction()

return { status: true, eventId }
} catch (error) {
console.error(`[createTask] ${task.id}:`, error)
await session.abortTransaction()
} finally {
await session.endSession()
}
}

// Сервис "Делатель"
/**
* @param {import('mongodb').MongoClient} client
* @param {import('mongodb').Db} db
* @returns {Promise<void>}
*/
export async function executor (client, db) {
// атомарно «забронировали» событие
const event = await db
.collection('outbox')
.findOneAndUpdate(
{ type: 'task.created', pickedAt: null },
{ $set: { pickedAt: Date.now() } },
{ sort: { createdAt: 1 }, returnDocument: 'after' }
)

if (!event) {
//нет событий
delay(100)
return
}

// идемпотентность на уровне обработчика
const processedId = `${event._id}:executor`
const processed = await db.collection('processed').findOne({ _id: processedId })

if (!processed) {
// уже делали — пропускаем
return
}

const task = await db.collection('tasks').findOne({ _id: event.payload.taskId, status: { $ne: 'done' } })

if (!task) {
// таск уже кто-то сделал
return
}

if (task) {
try {
// делаем таску, помним про идемпотентность
await doWork(task)
await db.collection('processed').insertOne({ _id: processedId, at: Date.now() })
await db.collection('tasks').updateOne({ _id: task._id }, { $set: { status: 'done' } })
await db.collection('outbox').updateOne({ _id: event._id }, { $set: { doneAt: Date.now() } })
} catch (error) {
// ретраи с backoff, лимит → DLQ
console.error(`[executor] ${task.id}:`, error)
await db.collection('outbox').updateOne({ _id: event._id }, { $set: { pickedAt: null } })
await db.collection('processed').deleteOne({ _id: processedId })
await delay(100)
}
}
}
// executor запускается setInterval или setTimeout выше по архитектуре

В примере важное: Задача и событие родились в одном коммите — значит, не потеряются. Исполнитель читает из той же БД — меньше инфраструктуры, меньше точек отказа. Цена — ты сам хозяин ретраев и DLQ, но это как раз то, что нужно контролировать.

Уже слышу крики: "А как же изоляция, границы, связность, какой "ужос" два сервиса работают с одной бд. Как же Чистая архитектура?!" Нормально, отвечу я, когда два сервиса читают одну БД: один владеет записью и инвариантами, остальные читают только стабильную проекцию/реплику по контракту — вот и вся изоляция. Ну это мы отвлеклись. Продолжим.
👍3🔥3
Вариант Б — RabbitMQ (внешняя очередь)

Наивно делать «insert в БД и затем publish в Rabbit» нельзя: между этими шагами всегда есть окна потерь/дублей. Значит… сюрприз… всё равно нужен outbox. Просто вместо прямого чтения его, будет «ретранслятор в Rabbit».


javascript
// Сервис "Планирователь"
export async function createTask (client, db, task) {
//createTask такой же как и в Варианте А, только меняем немного запись
//...
await db.collection('outbox').insertOne(
{
_id: eventId,
type: 'task.created',
payload: { taskId: task.id },
createdAt: Date.now(),
pickedAt: null,
publishedAt: null
},
{ session }
)
//...
}

// но тут нам нужен еще relay - штука по доставке события из бд в очередь

/**
* @param {import('mongodb').Db} db
* @param {import('amqplib').Channel} rmqChannel
* @param {string} exchange
* @returns {Promise<void>}
*/
export async function relay (db, rmqChannel, exchange) {
const event = await db
.collection('outbox')
.findOneAndUpdate(
{ type: 'task.created', pickedAt: null },
{ $set: { pickedAt: Date.now() } },
{ sort: { createdAt: 1 }, returnDocument: 'after' }
)

if (!event) {
await delay(50)
return
}

try {
await publishConfirm(rmqChannel, exchange, 'task.created', Buffer.from(JSON.stringify(event)))
await db.collection('outbox').updateOne({ _id: event._id }, { $set: { publishedAt: Date.now() } })
} catch (e) {
await db.collection('outbox').updateOne({ _id: event._id }, { $set: { pickedAt: null } })
await sleep(100)
}
}

// Сервис "Делатель"
/**
* @param {import('mongodb').Db} db
* @param {Object} msg
*/
export async function consumeAndExecute (db, msg) {
const event = JSON.parse(msg.content.toString())

// идемпотентность на уровне обработчика
const processedId = `${event._id}:executor`
const processed = await db.collection('processed').findOne({ _id: processedId })

if (!processed) {
//дубль, уже исполнили
return 'ack'
}

const task = await db.collection('tasks').findOne({ _id: event.payload.taskId, status: { $ne: 'done' } })

if (!task) {
return 'ack'
}

if (task) {
try {
// делаем таску, помним про идемпотентность
await doWork(task)
await db.collection('processed').insertOne({ _id: processedId, at: Date.now() })
await db.collection('tasks').updateOne({ _id: task._id }, { $set: { status: 'done' } })
return 'ack'
} catch (error) {
console.error(`[consumeAndExecute] ${task.id}:`, error)
// ретраи с backoff, DLX/TTL, лимит → DLQ, можно ответить 'nack' получим еще раз
await db.collection('processed').deleteOne({ _id: processedId })
return 'ack'
}
}
}

А где минусы спросишь ты. Так вот же, в полтора раза больше кода, больше мест, где, что-то может пойти не так. Дополнительный сервис с брокером. Транспорт между брокером и сервисами. Если не делаешь relay — потеряешь события при падениях между БД и брокером. Если не делаешь inbox и идемпотентность — словишь дубль при redelivery/повторной публикации. Rabbit сам по себе не решает твою бизнес-консистентность, он только перевозит байты.

Внутрисистемный асинхрон: Mongo + outbox закрывает 90% кейсов без зоопарка, и даже фан-аут тоже можно сделать. Но если хочешь фан-аут на внешних потребителей из коробки, независимые группы, интеграции или просто очень хочется очередь — бери Rabbit (или другой брокер), но не забывай: outbox/inbox, idempotency, confirm-публикации, DLQ. Очередь без этих штук — просто дорогая труба с иллюзией надёжности.
🔥2
Что в итоге?

Очередь — не дефолт. Это тяжёлая зависимость, которая тащит за собой протоколы, идемпотентность, эволюцию схем, эксплуатацию и новые точки отказа.

Нужен буфер? Сделай backpressure и честные 429.
Нужна обработка «не в запросе»? Outbox и воркер.
Нужен событийный продукт между доменами? И тут все еще можно обойтись без брокера, но уже можно задуматься про него, выбрать, закатать рукава и решиться нырнуть в асинхронный персональный ад.

Не тащи очереди «потому что так делают все». Тащи их, когда без них не сходится модель отказов и нагрузок. Во всех остальных случаях это просто ещё один слой боли.

Хочешь поспорить — покажи, где у тебя backpressure, где идемпотентность и как ты репроцессишь хвост. Если ответ «никак» — очередь тебе не помогла, она просто спрятала проблему в тень.
👍3🔥2
4. Зачем тебе этот грязный TypeScript?

Продолжим полоскать твой TypeScript. Прошлые посты про него получились довольно мягкими и обтекаемыми. Это непорядок, попробую прямо донести мысль: «TypeScript несёт в твой код шум, грязь, и заставляет тебя гуглить»

Тебе правда нужны вот эти вот всё условные типы, дженерики и танцы с infer? Если начистоту, в 90% случаев ответ: «точно нет»

TypeScript раздувает код и буквально заставляет держать в голове две модели. Предметная логика отдельно, типовые «заклинания» отдельно. В итоге, рано или поздно, ты начнёшь больше времени тратить на дебаг аннотаций, чем бизнес логики. А упадёт всё равно в рантайме.

TS буквально раздувает площадь кода. Любая правка превращается в две: логика и инварианты + синтаксический ритуал вокруг этого. Дифы пухнут, мерж-конфликты из-за :Foo|Bar вместо реального поведения. Ошибки типов не ловят всё то, что реально валит прод: регресс в протоколе, гонки, backpressure, дедлоки.

Типы – это твоя статическая гипотеза.
Рантайм – динамический факт.


С ростом проекта, ты становишься заложником типовой сети. Ты просто боишься что-то менять, одно переименование и у тебя 200 ошибок в разных файлах, а если это DTO, то может и в разных проектах. IDE с другим проектом, поможет тебе только после сборки и синхронизации пакета с типами. Это значительно тормозит рефакторинг – главный инструмент борьбы с энтропией.

А если ты встречаешь что-то сложнее чем Record<string, User>, ты полезешь гуглить: “Как эту вашу лабубу выразить через TS”. И случается это чаще, чем ты хотел бы, чаще чем ты читаешь свой код.

Советы от IDE подменяют дисциплину инвариантов и валидации на входе.
👍2🔥2
Пройдемся по примерам. (Все примеры вымышленные, любые совпадения случайны)

1) Утилита на бэке filterByTag.

typescript
// user.ts
export interface User {
  id: string;
  readonly tags?: readonly string[];
  email?: string;
}

// filter-by-tag.ts
import { User } from 'user.ts'

export function filterByTag<T extends Readonly<User>>(
  users: readonly T[],
  tag: string
): T[] {
  return users.filter(u => Array.isArray(u.tags) && u.tags.includes(tag))
}

// usage.ts
import { User } from 'user.ts'
import filterByTag from 'filter-by-tag.ts'

const vipUsers = filterByTag<User>(allUsers, 'vip')

Шум: интерфейсы, readonly, дженерик T, типовые импорты.

javascript
// user.mjs
/**
* @typedef {object} User
* @property {string} id
* @property {string[]} [tags]
* @property {string} [email]
*/

// filter-by-tag.mjs
/**
* @param {User[]} users
* @param {string} tag
* @returns {User[]}
*/
export function filterByTag(users, tag) {
return users.filter((u) => Array.isArray(u.tags) && u.tags.includes(tag))
}

// usage.mjs
import { filterByTag } from './filter-by-tag.mjs'

const vipUsers = filterByTag(allUsers, 'vip')

JS с JSDoc быстрее читается, без типового мусора, логика не теряется.

2) HTTP JSON-клиент с таймаутом

// http-client.ts
export async function getJson<T>(url: string, options: { timeoutMs?: number } = {}): Promise<T> {
const abortController = new AbortController()
const timeout = setTimeout(() => abortController.abort(), options.timeoutMs ?? 5000)
try {
const res = await fetch(url, {signal: abortController.signal})

if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}

const json = await res.json() as unknown

if (!json || typeof json !== 'object') {
throw new Error('Bad JSON')
}

return json as T
} finally {
clearTimeout(timeout)
}
}

// usage.ts
type Order = { id: string; price: number }
const order = await getJson<Order>('http://svc/order/42', {timeoutMs: 3000})

Шум: обязательные дженерики в вызовах, лишние as-касты.

// http-client.mjs
/**
* @template T
* @param {string} url
* @param {{timeoutMs?:number}} [options]
* @returns {Promise<T>}
*/
export async function getJson (url, options = {}) {
const abortController = new AbortController()
const timeout = setTimeout(() => abortController.abort(), options.timeoutMs ?? 5000)

try {
const res = await fetch(url, { signal: abortController.signal })

if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}

const x = await res.json()

if (!x || typeof x !== 'object') {
throw new Error('Bad JSON')
}

return /** @type {T} */ (x)
} finally { clearTimeout(timeout) }
}

// usage.mjs
/**
* @typedef {Object} Order
* @property {string} id
* @property {number} price
*/

import { getJson } from './http-client.mjs'

const order = /** @type {Order} */ (await getJson('http://svc/order/42', { timeoutMs: 3000 }))

В JS можно оставить только JSDoc, без кастов в коде и без генерации типов в рантайме.
2👍2
3) Реестр обработчиков событий

 Typescript
// events.ts
type UserCreated = { type: 'user.created'; payload: { id: string } }
type BillingPaid = { type: 'billing.paid'; payload: { invoiceId: string; amount: number } }
type Event = UserCreated | BillingPaid

type Handler<T extends Event = Event> = (event: T) => Promise<void>;
const handlers: Partial<Record<Event['type'], Handler>> = {}

handlers['user.created'] = async (event) => { /* ... */}
handlers['billing.paid'] = async (event) => { /* ... */}

export async function dispatch(event: Event) {
const fn = handlers[event.type]
if (fn) {
await fn(event)
}
}

Шум: дженерики, сложные типы Partial<Record<...>>, касты вместо проверки данных.

// events.mjs
// длинная запись
/** @typedef {Object} UserCreated
* @property {string} type - 'user.created'
* @property {object} payload
* @property {string} payload.id
*/

/** @typedef {Object} BillingPaid
* @property {string} type - 'billing.paid'
* @property {object} payload
* @property {string} payload.invoiceId
* @property {number} payload.amount
*/

/** @typedef {UserCreated | BillingPaid} Event */

// но можно и коротко
/** @typedef {{ type:'user.created',  payload:{ id:string } }} UserCreated */
/** @typedef {{ type:'billing.paid', payload:{ invoiceId:string, amount:number } }} BillingPaid */

/** @typedef {UserCreated | BillingPaid} Event */

// я предпочитаю длинный формат

/** @type {Record<string, (event:Event)=>Promise<void>>} */
export const handlers = Object.create(null)

handlers['user.created'] = async (event) => { /* ... */ }
handlers['billing.paid'] = async (event) => { /* ... */ }

function isEvent (event) {
if (!event || typeof event !== 'object') {
return false
}

if (event.type === 'user.created') {
return typeof event.payload?.id === 'string'
}

if (event.type === 'billing.paid') {
return typeof event.payload?.invoiceId === 'string'
&& typeof event.payload?.amount === 'number'
}

return false
}

/**
* @param {Event} event
* @return {Promise<void>}
*/
export async function dispatch (event) {
if (!isEvent(event)) {
throw new Error('Bad event')
}
const fn = handlers[event.type]

if (fn) {
await fn(event)
}
}

На JS проще — обычные typedef’ы и валидация по факту, без накрутки типов, надежнее.

4) Проекция из репозитория (списки/сводки)

export interface User {
id: string;
email: string;
name: string;
status: 'active' | 'blocked';
tags?: string[];
}

export async function listUsers(): Promise<User[]> {
return JSON.parse(await readFile('./users.json', 'utf8')) as User[]
}

export type UserSummary = Pick<User, 'id' | 'email' | 'status'>

export async function listUsersSummary(): Promise<UserSummary[]> {
const users = await listUsers()
return users.map(({id, email, status}) => ({id, email, status}))
}

Шум: Pick<User, ...>, обязательный as User[] при парсе, интерфейсы ради пары полей.
 javascript
/** @typedef {Object} User
* @property {string} id
* @property {string} email
* @property {string} name
* @property {string} status - ['active','blocked']
* @property {string[]} [tags]
*/

/** @returns {Promise<User[]>} */
export async function listUsers () {
return JSON.parse(await readFile('./users.json', 'utf8'))
}

/** @returns {Promise<Array<Pick<User,'id'|'email'|'status'>>>} */
export async function listUsersSummary () {
const a = await listUsers()
return a.map(({ id, email, status }) => ({ id, email, status }))
}

JSDoc закрывает типы, код остаётся линейным, без отрыва от логики.
2👍2
Что в итоге?
Да не нужОн тебе этот TS. Ну ладно, иногда всё-таки нужен.

TS не та экосистема, которую нужно тащить по дефолту, TS – инструмент точечного применения.

Например, когда у тебя публичный SDK – типы оправданы. Их читает не только твоя команда, IDE всем подсказывает сигнатуру, автокомплитит, подсказывает, как не промазать по контракту. В данном случае TS хорошо поможет с саппортом и навигацией по поверхности апи.

А еще, если у тебя сложный формат обмена с третьими командами – тут тоже сможет помочь. Но есть нюанс, сначала нужно завезти формальные схемы (JSON Schema, OpenAPI, Avro), а типы генерировать из них, не самому писать. Основная цель - не забыть, что договорились именно о таком виде полей. Типы тут помогут держать эту информацию на виду. Типы – побочный продукт протокола в данном случае, а не религия размазанная по коду.

Если у тебя много автогенерации, тоже сойдёт. Типы ты получаешь из источника истины, не пишешь их сам. Тут документация и навигация, не новый слой сложности. Генерируешь, используешь, не трогаешь.

Ну вот как бы и всё.

За всё остальное ты будешь платить скоростью и когнитивной нагрузкой, а еще гугл мучить. Но это еще не всё побочки. Статическая гипотеза, которую ты выдвигаешь, внушает тебе ложное чувство безопасности и надёжности, а как я писал в начале, упадёт всё равно в рантайме.

Мы делаем сервисы, а не курсовую по теории типов. Надёжность держится на четырёх столбах: тесты, контракты на границах, валидация на входе и простая архитектура без фетишей. Всё это прекрасно живёт на чистом JS.

Буду рад услышать, где ты со мной не согласен. Особенно про кейсы: "Ну вот же, у меня вот тут TS всех спас!", будет интересно разобраться, тип это был или всё-таки дисциплина.

Другое про TS:
Eric Elliott — The TypeScript Tax
Desphil Boy — Typescript doesn’t make sense

Прошлые посты:
0. Что даёт TypeScript, кроме типов
1. TypeScript не гарантирует надёжность
2. TypeScript ломает zero-deps и замедляет цикл разработки
3. Когда TypeScript оправдан
👍2🔥2
Date.now может убить твой RPS

Отвлечемся на более прикладную тему – микрооптимизацию.
Уточню сразу, для 99% систем, это не нужно, не те нагрузки, не тот RPS, влияние микроскопическое.
Но если у тебя на hot-path уже набегает до 100k RPS и каждый тик на счету, об этом уже можно подумать.

Вот возьмем совершенно с виду безобидный Date.now(), статический метод возвращает миллисекунды с 1970-01-01T00:00:00Z. Используют его повсеместно для метрик, вычислений, логов.

«Ну читает таймер и читает, что тут такого?» - для обычной системы в целом ничего.  Для нагруженной — это лишний системный вызов (или почти системный), прямо внутри горячего цикла.

Как это работает?
Date.now не просто читает регистр или "переменную", он читает системное время.

Под капотом:
1. JS → V8 intrinsic → C++ JSDate::CurrentTimeValue(isolate).
2. Системный вызов:
   - Linux: clock_gettime(CLOCK_REALTIME)
   - Windows: GetSystemTimeAsFileTime()
3. Перевод в миллисекунды и возврат в JS.

Вызов идёт синхронно. Eventloop не задействуется. Date.now() не кеширует значения и каждый раз ходит в системный API. Монотонность не гарантируется, системное время может корректироваться (например, через NTP).

Цена вопроса (примерный порядок):
- vDSO (`clock_gettime` без перехода в ядро): 30–80 нс.
- С переходом в ядро (syscall): 300–500 нс.
- На 100k RPS × 3 вызова на запрос = 300k вызовов/с.
- При 0.3 мкс на вызов — ~90 мс CPU/с (около 10% одного ядра) только на чтение времени.

А как проверял?
Стенд предельно прямолинейный, без зависимостей.
server.mjs — два ендпоинта, разница только в том, где вызывается Date.now():

// /bad — дергаем Date.now() внутри цикла
function workloadBad(n) {
  let s = 0
 
  for (let i = 0; i < n; i++) {
  s += (Date.now() & 1)
  }
 
  return s
}

// /good — один вызов Date.now() и работа со значением
function workloadGood(n) {
  const t = Date.now()
  let s = 0
 
  for (let i = 0; i < n; i++) {
  s += (t & 1)
  }
 
  return s
}

bench.mjs — шлёт запросы параллельно, считает RPS и перцентили p50/p95/p99 по длительности ответа последовательно для каждого ендпоинта.

Параметры запуска:
DURATION=10s, CONCURRENCY=32, LOOPS=200_000

Цифры:

Path | RPS   | p50  | p95  | p99  | req   
-----|-------|------|------|------|-------
/bad | 218.9 | 143  | 149  | 290  | 2189 
/good| 8023.2| 3.86 | 4.01 | 7.74 | 80232


Это не «нанопроценты». Это порядок разницы. Просто перестали дёргать Date.now() миллионы раз в горячем цикле.

А какая альтернатива? И когда заменять?
Оговорюсь, не стоит бросаться и выжигать Date.now повсеместно. Стоит учитывать контекст.

— Хочешь измерять интервалы — используй монотонные таймеры:
performance.now() (мс с долями, монотонный) или process.hrtime.bigint() (нс). Они не завязаны на системные поправки времени и обычно дешевле. 
— Нужен штамп времени (для логов/бизнес-логики) — ок, Date.now(). Но один раз на запрос/батч, а не в каждой итерации.
— В горячем коде — вынеси чтение времени из цикла, работай с локальной переменной. Это дёшево и надёжно.

Что в итоге?
Date.now() — нормальный инструмент. Но в hot‑path многократные вызовы — это может быть overhead, который влечет за собой жирные p95/p99 и просадку RPS.

Оптимизация тривиальна: читаешь один раз или переключаешься на монотонный таймер там, где нужно мерить интервалы. Никаких фреймворков, никакой магии.
👍4🔥2
Монолит vs распределёнка. Выбираешь не топологию, а тип боли.

Ранее я уже трогал тему архитектуры: где она начинается, зачем нужна её чистота и как не скатиться в фетишизм. Настало время копнуть глубже — поговорим про топологию системы. Монолит против распределённой архитектуры.

Об эту тему сломано уже немало копий. Но если немного погрузиться в тему, то почти все архитектурные споры рано или поздно скатываются в лозунги:
«Монолиты — это прошлый век»,
«Микросервисы — это оверинжиниринг».

Я не согласен с обоими. Уже слышу из чата: «ну кто бы сомневался, автор опять набрасывает на вентилятор». Но надеюсь, что после прочтения ты хотя бы задумаешься: «может, автор не совсем ещё поехал?».

Для начала давай определимся с терминами и тем, что они из себя представляют.
👍2🔥2
Монолит

Итак, его величество Монолит. Это не обязательно спагетти-код. У него может быть хорошая модульная структура, хорошо прописанный DTO сквозь всю систему и хорошо собранный CI/CD-пайплайн.

Некоторые умельцы даже реализуют hot-reload модуля без рестарта всего процесса, но это уже экзотика.

В любом случае весь основной функционал всё ещё собран в одном исполняемом блоке: один деплой, единая база, общие модули.

В монолите границы между частями системы — логические, внутри одного процесса.

Достоинства:
- нет сетевых задержек, всё работает в памяти;
- проще трассировка и отладка: один процесс, один стек;
- деплой — один пакет, одна точка обновления;
- минимум инфраструктуры и зависимостей;
- быстрый старт и короткий цикл изменений;
- согласованность данных и транзакции из коробки;
- единый контекст исполнения без сериализации;
- локальная разработка без зоопарка сервисов;
- меньше DevOps-нагрузки.

Недостатки:
- масштабируется только целиком;
- сильная связность: падение одного модуля валит всё;
- релиз любого изменения тянет за собой весь проект;
- сложно изолировать сбои и деградировать частично;
- со временем риск вырасти в неподъёмного монстра;
- тяжело делить работу между командами в одной кодовой базе;
- долгие интеграционные тесты;
- миграции схемы и контрактов только одним большим релизом.

Монолит — это про скорость старта, скорость разработки, простоту и предсказуемость. Можно быстро собрать рабочий прототип без оверхеда на межсервисное взаимодействие и протоколы. Подходит для старта почти любого проекта, особенно когда бизнес хочет протестировать идею. Хорошо там, где важна согласованность, минимальная инфраструктура или бюджет на неё ограничен. А ещё это про «олдскул», «про хардкор» и уложить весь прод одной запятой.

Монолит очень любят PHP-шники — потому что там особого выбора и нет, вся экосистема росла на монолитах. Когда у тебя Laravel на shared-хостинге, никакой RabbitMQ с сервис-мешем просто не пролезет.
🔥3👍2
Распределённая архитектура

А теперь — королева хаоса, распределённая архитектура. Это когда части системы разнесены по разным узлам и общаются по сети. Нет, это ещё не микросервисы — но уже ближе к ним. Микросервисы — это частный случай распределённой топологии.

Под это понятие попадает всё: от двух жирненьких серверов, связанных gRPC-протоколом, до сотен микросервисов на паре десятков машин, связанных охапкой брокеров, сокетами и прямым православным HTTP.

Границы здесь уже физические: сетевое взаимодействие, форматы данных, протоколы, ретраи, таймауты.

Достоинства:
- изоляция отказов (если контракты соблюдены);
- независимые деплой и релизы компонентов;
- масштабирование только узких мест;
- гибкость в выборе технологий под задачу;
- можно делить работу между командами без войны за один репозиторий;
- проще внедрять или переписывать отдельные части;
- можно разворачивать разные части системы в разных средах.

Недостатки:
- сеть становится частью контракта: задержки, таймауты, ретраи;
- сложнее трассировка и отладка: один запрос проходит через десяток сервисов;
- нужна синхронизация данных, иногда с жертвой консистентности;
- инфраструктурные накладные расходы: брокеры, балансировщики, API-шлюзы, сервис-меши;
- выше требования к мониторингу и логированию;
- рост операционной сложности: деплой, тесты и отладка требуют координации.

Распределёнка — это про изоляцию, гибкость, масштабирование и управляемую деградацию. Позволяет выбрать отдельный технологический стек для отдельной задачи. Подходит, когда система уже выросла за рамки одного процесса, команды работают параллельно и бизнес требует высокой доступности.

Но вместе с этим ты подписываешь контракт с сетью, мониторингом и DevOps-шапито. А ещё получаешь шанс провести несколько бессонных ночей, разбираясь, почему этот грёбаный сервис на 200 строк в проде отвечает 499 — и только по пятницам.

Тут уже любителей больше — джависты, гошники, писатели на Node.js — просто потому, что их стеки уже живут в мире брокеров, Kubernetes и сервис-мешей. Когда у тебя Spring Boot, Go-kit или NestJS в обнимку с Docker, всегда проще накатить новый сервис, чем ковырять старый.

«А где питухонисты?» — спросишь ты. Там сложнее. У них картинка где-то между PHP и Node.js. Много монолитных Django/Flask-приложений, особенно в вебе. Но они тоже не будут против распределёнки, особенно если её можно склеить из Flask, FastAPI и пары очередей, а деплоить через kubectl apply и скрипт на 50 строк. Python часто в компаниях соседствует с Go, Java или Node.js, и тогда питонячие куски становятся сервисами внутри уже готовой распределённой платформы.
🔥3👍2
Что в итоге?

Выбор топологии — это первый серьёзный архитектурный компромисс. Он определяет, какими проблемами ты расплатишься за достоинства и какие инструменты будешь тянуть за собой.

Монолит даст быстрый старт и контроль, но упрётся в масштабирование по частям.

Распределёнка даст гибкость и изоляцию, но привяжет к вечной возне с сетью и «дебагу призраков»

Нет «лучшей» топологии. Есть та, что подходит под твои задачи и ограничения.

И да, стартовое решение не высечено в камне — монолит можно распилить на сервисы, а микросервисное болото превратить в один аккуратный монолит. Но миграция почти всегда дороже, чем тебе будут обещать разработчики.

Если коротко: монолит — «быстро запустили, потом разгребаем», распределёнка — «долго строили, потом разгребаем». Разница только в том, чем и где ты будешь грести.


А что еще почитать?
- Мартин Фаулер — Monolith First - почем для начала лучше монолит
- Roadmap for Managing Chaos — Planing Migration from a Monolith to Microservices - этапы перехода от монолита к микросервисам
- GOTO 2019 • Monoliths vs Microservices • Martin Fowler - когда и почему каждая топология работает или ломается
🔥2
Очереди все еще не нужны.

Каждый  разработчик думает об архитектуре нового проекта,  и часто приходит к такому заключению: «Завезу микросервисы, и чтоб красиво кафку туда, или реббит, чтоб общались. Редис для кешей, и все это в кубере. Заживу!». 

Обоснование тому такое: «Задел на будущее, кучу систем так построили, нормально работает, даже не больно, практически. И распределенка, и отказы с пиками держим. Красота!». Я мог бы согласится, но не буду. Если начать копать глубже, всплывает много нюансов. Большую часть того, что всплывает, я описал в прошлый раз

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

Расскажу что собирал, как и что измерял и какие результаты получил.
👍4🔥3
Часть первая: Преамбула 

Начну издалека, с теоретического обоснования, как учили еще в университетах. 

Есть у тебя сервис А. Базу свою этот сервис и в хвост и в гриву, поэтому запись там медленная — допустим, 60 мс. Сервис А принимает на эндпоинт /task задачу, а бизнес вместе с SRE говорит тебе как архитектору:
«Ждать ажно 60мс никак не можем, ответить нужно за 50 и точка. Zero loss, идемпотентность, at-least-once. Ещё давай, чтобы graceful degradation было, throughput не падал, latency стабильно в SLA. Ordering guarantees можно не жёсткие, но durability кровь из носа. И чтоб backpressure обязательно. Завтра релиз».

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

Формализуем требования: 
- SLA входа: p99(t_api) ≤ 50 мс при 500 RPS (без аварий).
- At-least-once доставка без дублей в целевой БД (идемпотентность по `id`).
- Durability на приёме: после ответа клиенту запись потеряться не должна при падении сервиса/воркера/базы.

Остальное как бы не услышал, оно тебе не важно.

Уяснив требования бизнеса и хотелки SRE, наливаешь кофу и начинаешь думать, какие варианты у тебя есть и чего эти варианты тебе будут стоить.
🔥6