Назад к articles
article

Как мы разрабатываем — инженерные принципы при работе над реальными продуктами

Постараемся перечислить все правила, которым в Laraue следуют при разработке. Мы ссылаемся на этот документ из отдельных статей, чтобы не повторять принципы в каждой из них. В некоторых пунктах присутствуют ссылки на истории, при которых это правило появилось.

Проектируем базу и пишем код только когда понятен пользовательский путь

Прежде чем переходить к базе или коду, мы расписываем последовательность действий, которые пользователь совершает для достижения какого-то результата. Цена исправления ошибки кратно растет при переходе к каждому последующему этапу разработки. Исправить ошибку в макете — 2 минуты, переписать код фичи — 2 дня. Поэтому сначала описываем путь пользователя, потом долго смотрим на него, пытаясь найти несоответствия. Переходим к проектированию базы и написанию кода в самую последнюю очередь.

Отсюда же следует, что код добавляется только в соответствии с текущими потребностями. Разработчику полезно знать, какие фичи планируются в следующих итерациях, чтобы закладывать расширение архитектуры в нужных местах. Но добавлять код или таблицы, не относящиеся к текущей фиче, в PR — прямой путь к техдолгу на ровном месте. Как следовать этому принципу на примере разработки Laraue Boards — в статье про путь пользователя.

Начинаем с простого, усложняем когда это действительно нужно

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

Во время разработки Laraue Boards каждое инфраструктурное решение следовало правилу: выбрать простейший и наиболее дешевый вариант, удовлетворяющий текущим потребностям; усложнять только при наличии конкретной причины. У облачной базы есть реальные преимущества — бэкапы, failover, point-in-time recovery — но платить за них имеет смысл тогда, когда есть что защищать. Когда у тебя три активных пользователя — self-hosted вариант на том же VPS, где крутится основное приложение, отлично справляется с задачей.

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

Используем многослойную архитектуру для сервисов — но только там, где это нужно

Мы выносим логику, которая не должна зависеть от точки входа в приложение, в проект Core сервисов (Services), а специфичную для хоста в сервисы хоста (HostNameServices). Core-сервис может выполнить операцию в том виде, в котором она будет корректна независимо от вызывающего: создание issue должно добавить запись в историю изменений независимо от того, пришёл запрос через Telegram-бота или web API. Host-сервис содержит только специфичную для хоста логику: проверку прав, валидации, управление транзакциями. После выполнения подобных действий запрос обычно перенаправляется в Core-сервис. Подробно подход разбирается в статье про архитектуру бэкенда.

Важно понимать причину появления правила: разделение помогает переиспользовать код в разных хостах, избегая дублирования логики. Если функциональность ограничена одним хостом — например, весь проект — это хост для Telegram-бота — деление может быть избыточным и приведет только к усложнению проекта и трате времени на поддержание архитектуры, которая ничем не помогает.

Не даем просачиваться интеграционным моделям в ядро приложения

Когда приложение зависит от внешнего сервиса, его типы не должны просачиваться внутрь домена. Крайне не рекомендуется хранить стороннюю модель Telegram.Bot.Types.Message в своей базе.

Правильный подход — определять свои модели и DTO для любых сервисных операций и маппить сторонние типы в них. Например, хост Telegram отслеживает появление новых сообщений в чате (Telegram.Bot.Types.Message), маппит их в локальный DTO (DomainName.Services.SaveMessageRequest) и работает дальше с этим DTO, как будто Telegram и не существовало, вызывая coreService.SaveMessage(SaveMessageRequest request).

Раз домен не знает, что интеграция существует, добавление новой (например, сохранение сообщения из другого чат-приложения) потребует добавить только новый слой интеграции, а не переписывать уже стабильный core-сервис.

Балансируем между скоростью разработки и правильностью архитектуры — и принимаем, что баланс может смещаться

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

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

Добавляем индексы при написании логики, а не при проектировании модели

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

Оптимизация запроса на несколько порядков важнее оптимизации кода

В веб-приложениях причина медленного ответа сервера почти всегда находится в запросах к базе, а не в коде. Один плохо составленный запрос — отсутствующий индекс, N+1, джойн, тянущий больше необходимого — может на порядки увеличивать общее время обработки ответа сервера. Поэтому, при проблемах со временем ответа, в первую очередь подозреваем запросы.

Это не значит, что код приложения можно писать как попало. Цикл с O(n²) по очень большой коллекции может легко заставить сервер задуматься. Но микрооптимизации, например замена деления x / 2 на x >> 1, не приносят выгоды в масштабах большого приложения, а только ухудшают читаемость кода.

Держим зависимости на минимуме и предпочитаем стабильные технологии

Каждая сторонняя библиотека — потенциальная неопределенность для продукта. Библиотеки забрасывают, меняют лицензии, ломают совместимость, когда посчитают нужным. Поэтому незапланированная работа может появиться в самый неподходящий момент — например, в версии библиотеки, что использовал продукт, появились уязвимости, а в новых версиях — уже breaking changes, из-за которых половину проекта придется переписать. Поэтому всегда стоит оценивать риски и решать: самостоятельно писать код или использовать готовое решение.

Это относится не только к библиотекам, но и к фреймворкам, базам данных. Стабильные технологии имеют задокументированные ограничения, их часто за что-то ругают. Зато они проверены временем и нет страха в один день услышать, что разработчик решения полностью переосмыслил подход и решил переписать все, сломав совместимость. Конкретный пример, как мы выбирали стек для Laraue Boards — в истории про выбор стека.

Пишем тесты после стабилизации продукта, а не до

Писать тесты для фичи, когда никто еще не знает, какую окончательную форму она примет, — расточительно. Методология TDD (разработка через тестирование) подразумевает идеальный мир, в котором бизнес четко знает, как должен работать разрабатываемый функционал. Факт в том, что до того, как продукт попробуют тестовые или реальные пользователи, этого неизвестно.

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

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

Всегда есть исключения из правил

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