Проект это сложная история. Обычно это относительно сложное и длительное мероприятие, создать программный продукт и провести его через стадию активной разработки до первой реальной коммерческой эксплуатации. Лично я видел как этот путь, в среднем, занимал от 2 до 5 лет на проекте с большой командой разработчиков.

Фишка в том, что нужно обеспечить качество продукта. Нас за этим (как разработчиков) и нанимают на работу, чтобы мы правильно писали программные продукты и прикладывали усилия чтобы эффективно двигать продукт вперед. Но на выходе, причем иногда еще до реального большого релиза, продукт может обрасти таким количеством технического долга, что скорость разработки очень сильно падает, и кодовая база приобретает характеристики которые свойственны кодовым базам которые сложно поддерживать и развивать. И парадокс в том что все при этом действительно стараются сделать хороший программный продукт. То есть, как говорится, хотели как лучше, а получилось как всегда.

Поделюсь наблюдениями и личными выводами почему это происходит.

Чтобы не тянуть резину – я пришел к выводу что основная причина в большей степени кроется в процессах производства, а не в компетенции разработчиков.

Чтобы объяснить и не разводить 100 страниц с теорией, начну с такого тезиса – каждая задача требует определенной технической сложности для реализации.

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

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

Первое (машину распилили и переварили с новой деталью) – это результат реализации без предварительной подготовки. Архитектура не позволяет принимать новый код без значительного изменения существующих связей (равно "надо распиливать").

Второе (машину доработали, добавили новое) – результат благодаря архитектуре которая работает на расширение.

Сказать просто, но на деле есть разные причины почему второе сложно достижимо, вот пара больших причин:

  • Мы просто не можем знать что мы будем разрабатывать в ближайшие N-лет. Если на стадии активной разработки это еще более-менее предсказуемо, то на этапе когда продукт развивается исходя из активно изменяемых векторов бизнеса это и вовсе невозможно, это всегда импровизация. Но как правило продукт начинает развиваться как поток импровизации еще задолго до релиза в прод.

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

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

Теперь переведем это в другое русло – мы имеем и серию других зависимостей которые имеют эти же причины:

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

  • Технический долг означает ограничения для внесения новых изменений или развития продукта, что, в свою очередь, вынуждает или находить обходные решения ("костыли") и создавать еще больше технического долга, либо разрешать (решать) его.

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

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

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

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

Для того чтобы это разрешить, получается, нам нужно:

  • Закладывать ресурсы на разрешение технического долга в каждую задачу

  • Закладывать ресурсы на регресс в целях поиска точек деградации качества продукта

  • Изменять старый код в кодовой базе и изменять десятки и сотни мест использования измененного кода (адаптация кодовой базы к новым изменениям)

  • Вовремя всё это вспомнить, заложить в задачу и сделать

Знаете когда это действительно достигается на практике? – Крайне редко

В реальности есть целая серия причин почему это может быть не достигнуто, вот несколько для примера:

  • Разработчику не хватает опыта и он не видит проблемы в том как сейчас написан код в кодовой базе

  • Разработчик хочет просто решить задачу и не закладывает "рефакторинг", а просто там где не сложно добавит "костыль" и всё "заработает"

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

  • Управление считает что если сделано, значит правильно и хорошо, и больше изменения в старых решениях не потребуются. Поэтому запрос на время на разрешение технического долга надо аргументировать (зачем, как это поможет бизнесу?), и как-правило выбить время на это сложно

  • Технический долг накопился в таком количестве, что его разрешение потребует огромных трудозатрат что просто неприемлемо для менеджмента, и поэтому, поскольку управление не согласует реальные правильные сроки, технический долг не разрешается, а новые решения пишутся в обход (снова "костыли")

Я уверен что каждый из вас найдет еще по 10 примеров почему технический долг не решается на вашем проекте.

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

Это стандартная картина на очень и очень многих проектах.

Про технический долг

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

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

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

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

Что делать

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

Основная причина (в моем мнении) возникновения технического долга – это некачественное проектирование, что приводит к невозможности принятия изменений максимально на расширение.

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

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

Выходит, можно переформулировать тезис таким образом – чем дальше модули кодовой базы от медианы по количеству зависимых модулей, тем ниже качество кодовой базы и продукта.

Если развить тему

Это правило на самом деле применимо и к любым другим техническим сущностям – переменным, функциям, классам, и так далее.

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

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

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

Я расскажу дикий пример.

Однажды я работал в команде, которая работала с обычными спринтами по две недели. Но в команде не было контроля технических процессов. Порядка 20+ разработчиков писали код исключительно как считали правильным, и мержили изменения в девелоп строго раз в 2-4 недели. Дикое количество конфликтов, множество дублей в системе, но это работало, и скорость разработки была космическая.

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

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

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

Если посмотреть на это через граф зависимостей, это позволило не создавать сверхответственные модули (которые обычно есть при обычном подходе к проектированию), и поэтому аффект от технического долга, как и стоимость изменений стала не космической и затрагивающей многие сегменты кодовой базы, но лишь отдельные её участки. Это сильно снизило стоимость изменений при наличии технического долга, и если команде надо было подкорректировать что-то, они это делали без необходимости «рефакторить» всё приложение.

Таким образом получилось достигнуть:

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

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

  • Самое главное, появилась возможность держать и масштабировать скорость разработки без потери в качестве. Буквально, управление могло добавить еще несколько небольших групп разработчиков по 3-4 человека, и они могли независимо писать новые решения почти без онбординга.

Конечно, такой подход не лишен своих минусов, но такая команда смогла добраться до парити по фичам со старым 10-ти летним проектом всего за 2 года, и при этом стабилизация фичей сроком разработки в квартал занимала не более 1-2 недель.

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

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

Что делать на типовом проекте

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

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

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

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

В таком случае при возникновении новых требований порог перехода кода в категорию технического долга будет сдвинут и «рефакторить» придется меньше.

Небольшие выводы

Всё выше создает несколько выводов:

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

  • На проекте должен быть архитектор для контроля связности в кодовой базе

  • Нет ничего плохого в том, что сделать дубль модуля и подкорректировать под свои задачи (но без фанатизма, баланс нужно искать)

  • Работа с техническим долгом должна быть частью задач

  • Код на выходе решения должен сразу иметь возможность простого расширения

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

  • Нужна культура разработки и качественные процессы которые позволят фокусировать разработку на решении текущих и возможных будущих сложностей уже сейчас

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

Поэтому вот рецепт, который позволит писать хорошие решения (архитектурно), не терять в качестве или скорости разработки, и при этом иметь возможность свободного масштабирования команды:

  1. Примите факт, что технический долг – это наиболее важный негативный аспект в марафоне по производству технического продукта

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

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

В таком случае если ошибки разработчиков и будут допущены, на любом этапе разработки их будет сильно проще исправить, чем это обычно бывает на долгосрочных проектах.

Комментарии (4)


  1. ruomserg
    25.11.2023 09:53
    +19

    О, как мы срались с сертифицированными архитектами!.. Их позиция - архитектура решения определяется, в основном, QA и NFR вытекающими из ТРЕБОВАНИЙ КЛИЕНТА. Моя позиция - клиент, если он пошел к архитекторам, в 99% случаев сам не знает чего хочет. И в бОльшей половине случаев - это не отсутствие мозгов у клиента, а отсутствие знаний о будущих событиях (как пойдет бизнес, как отреагируют клиенты, как будет двигаться регуляторика и проч). И поэтому когда мы с умным видом рисуем вытекающие из незнания диаграмы - это попытка не замечать слона в комнате (ну и возможность, если что - оправдаться и не стать крайним за неудачный проект).

    Я вижу три столпа хорошей архитектуры. Первый - это опыт в предметной области. Если клиент еще не знает свое будущее, а вы на другом клиенте уже потренировались - то это гигантский плюс к построению нормальной архитектуры и к успеху всего предприятия. Примечание: учитывая Брукса - лучше тренироваться на 2 клиентах. Вторая система - обычно галимый оверинжиниринг, когда делается попытка обобщить опыт с единственного примера.

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

    Третий столп - помните, что ваша архитектура должна быть в основном о требованиях, которые в настоящий момент неизвестны и не могут быть легко выявлены. Вы обязаны заложить достаточную гибкость и точки расширения, чтобы реагировать на черных лебедей без переписывания всей системы. Поэтому у нас от кончика пальца до плеча - аж 6 суставов (хотя из соображений теоретической механики должно хватать трех). При этом, закладывать излишнюю гибкость, и предусматривать 20 суставов или делать аналог щупальца осьминога тоже не стоит.

    В общем, опыт и здравый смысл... Здравый смысл и опыт... И других хороших вариантов я не вижу. Автору статьи - респект за поднятую тему и примеры!


  1. EvgeniiR
    25.11.2023 09:53
    +1

    По поводу модулей и зависимостей. Я думаю хорошая модель для объяснения и для нахождения плохо-спроектированных модулей это 1 - Coupling/Cohesion - модули должны иметь как можно меньше связей с другими модулям, а связанность должна быть инкапсулирована внутри модулей. 2 - Afferent/Efferent coupling - количество входящих/исходящий зависимостей классов/модулей. Если класс стабильный и от него многие зависят = ок, делать такой класс зависимым от других и следовательно менее стабильным = не ок.

    Подробно не расписываю, рекомендую просто погуглить + небольшие книжки Роберта Матрина где он определяет эти понятия, разобраться с типами связанности. Плюсы такой модели в том что есть готовые инструменты которые автоматически подсчитают упомянутые метрики, и дадут хорошую базу для обсуждения возможного рефакторинга. Так же пользу упомянутых моделей легко объяснить исходя из предпосылки что держать в уме содержимое одного модуля проще проще чем содержимое нескольких связанных с собой модулей(к чему может принуждать высокий Coupling)


    1. ruomserg
      25.11.2023 09:53

      А вот в эту сторону, пожалуйста очень аккуратно! Максимум для чего я рекомендовал бы использовать такие метрики - это для обсуждения альтернатив решений (сделать так, сделать этак). А вообще, как и все метрики - они дают вам динамику, и подсказки о том, где были приняты потенциально неправильные решения.

      Как только вы начнете использовать метрики per se для принятия решений о том, что слить и что разлепить в коде - они станут целью и перестанут хорошо измерять то, ради чего предназначены.


      1. EvgeniiR
        25.11.2023 09:53
        +1

        Всё так. Это метрики для поиска совсем плохих мест, а не чтобы встроить их в пайплайн коммитов