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

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

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

zerodeps.tech
Download Telegram
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
Часть вторая: Кейсы

Возьмем сферический проект в вакууме, представим, что у тебя нет ничего кроме как api и базы. Api напрямую пишет задачу в бд и беспощадно нарушает SLA. 

"Чтож делать? Чтож делать?"

Кейс 1. WAL (Write-Ahead Logging)

Старый “советский” метод. Api пишет не в базу, а в локальный журнал. Формат тупой как гвоздь: длина + payload. Один fsync на батч или по таймауту. Ротация — по размеру и времени. Старый сегмент закрыли, новый открыли. Воркеры читают завершённые сегменты и докатывают в базу пачками, хоть по 100, хоть по 1000 штук.

Плюсы:
– Latency +-1 мс на входе (fsync по батчу/таймеру).
– Durability честная после fsync.
– At‑least‑once через идемпотентность в БД.
– Failover тривиальный (перечитываем сегмент).
– Естественный backpressure по росту сегментов.

Минусы:
– Дисковая нагрузка (особенно fsync=always).
– Своя реализация и поддержка (скорее всего реализации готовой не будет или будет написана индусом).
– Нужен мониторинг лага/ротации, контроль места на диске.

WAL – это "скучный" вариант, но и предсказуемый, сам сломал –сам чинишь. Нет брокеров, нет «кластеров», нет магии. Файл и логика: как писать, как читать, как ротировать. Стоит упомянуть, что все последующие кейсы в той или иной степени под капотом имеют схожий механизм обеспечения сохранности данных – fsync.

Кейс 2. Ingest DB

Тут уже будет нужен DevOps. Вместо того, чтобы писать в журнал, пишем сразу в базу — но не в боевую, а в отдельный инстанс (ingest), который не тормозит, заточен на быструю запись и физически крутится на другом сервере.  Уже из него воркер параллельно пачками переливает в нужную базу.

Плюсы:
– Durability на стороне СУБД.
– Дубликаты гасит ON CONFLICT.
– Масштабирование воркерами, размер пачки.
– Лаг прозрачен (ingest → main).

Минусы:
– Индексы на ingest могут тормозить вставку.
– Вакуум/блоат и размер пачки нужно тюнить.
– UNLOGGED нельзя при zero‑loss.
– Возможна гонка за ресурс на горячих партициях.
– Медленная/общая БД = боттлнек.

Получается, что IngestDB — это в теории решение между чистым WAL и брокером. Простая вставка на входе, батчи на выходе, минимум инфраструктуры. Теоретически хорошее решение, завез отдельную базу, затюнил и решил проблему. 

Кейс 3: Valkey (Streams с AOF)

Вот ты уже практически прям рядом с очередью. Еще не жирный брокер, но уже очередь на минималках. Берём Valkey (форк Redis), включаем streams и appendfsync=always. Тут как и с базой нужОн DevOps, лучше два, поднимаем новый отдельный сервис.

А работает кейс так: 
– api пишет событие в XADD stream * payload. Операция быстрая, но не бесплатная, каждый fsync блокирует запись.
– Клиент получает ACK сразу после попадания в AOF, значит durability честная.
– Воркер читает через XREADGROUP, обрабатывает пачку и помечает offset.

Плюсы:
– Низкая теоретическая базовая задержка на XADD.
– AOF даёт честную durability.
– At‑least‑once встроен (PEL/ACK) (с оговоркой, только на доставку, дубли не контролируются).
– Привычный стек (в теории).

Минусы:
– p99 скачет из‑за fsync на нагрузке.
– Backpressure кривой: растёт лаг, сервис ест память.
– Failover: потеря хвоста без WAIT/min‑replicas‑to‑write.
– Доп. сервис и зоопарк, рост RAM до краша.

Valkey хорош для UX-шных событий, коротких очередей, быстрых стримов, где пропускная способность важнее стабильной задержки. Но в задаче «zero loss + SLA по latency» valkey начинает подтекать: да, не потеряет, но предсказуемость страдает. В оправдание запишем тот факт, что скорее всего, valkey или redis уже будет на проекте как cache.
🔥5