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

Процесс улучшения структуры не меняет внешнее поведение

Работа по улучшению внутреннего устройства известна давно. Она состоит в изменении структуры кода без изменения его внешнего поведения.

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

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

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

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

Долг накапливается незаметно и выражается во времени

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

Типы долга различаются по причине возникновения.

Первый это осознанный долг. Команда знает о компромиссе и планирует исправить позже. Такой долг допустим, если есть план возврата.

Второй это случайный долг. Команда не понимала последствий решения. Это самый коварный вид, потому что о нём не помнят.

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

Четвёртый это долг от ухода людей. Автор уволился, знания исчезли, а код остался.

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

Чистая архитектура строится на разделении направлений зависимостей

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

Формулировка принципа обращения зависимостей звучит так. Модули верхнего уровня не должны импортировать ничего из модулей нижнего уровня. Оба должны зависеть от абстракций.

Разберём на примере. Приложение оформляет заказ. Правило предметной области: заказ нельзя оформить при нулевом количестве товара. Это правило не должно знать, хранятся данные в базе или в файле, приходят из веб-интерфейса или из командной строки.

from dataclasses import dataclass

@dataclass
class Order:
    quantity: int

    def validate(self):
        if self.quantity <= 0:
            raise ValueError("Количество должно быть положительным")


class OrderRepository:
    def save(self, order):
        raise NotImplementedError


class PlaceOrder:
    def __init__(self, repository: OrderRepository):
        self.repository = repository

    def execute(self, order: Order):
        order.validate()
        self.repository.save(order)
        return order

Здесь видно главное. Сценарий оформления заказа зависит от абстракции хранилища, а не от конкретной базы. Конкретная реализация появится отдельно, и подменить её можно без правки сценария.

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

class InMemoryRepository(OrderRepository):
    def __init__(self):
        self.items = []

    def save(self, order):
        self.items.append(order)


def test_negative_quantity_rejected():
    use_case = PlaceOrder(InMemoryRepository())
    try:
        use_case.execute(Order(quantity=0))
    except ValueError:
        return
    raise AssertionError("Ожидалась ошибка проверки")

Третье следствие это слои. Обычно выделяют три уровня: правила предметной области, сценарии применения и внешние адаптеры. Зависимости идут внутрь, к правилам, а не наружу.

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

Теперь о том, как это делать на живом проекте.

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

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

// было
def process(data):
    total = 0
    for item in data["items"]:
        if item["price"] > 0 and item["count"] > 0:
            total += item["price"] * item["count"]
    if total > 1000:
        total = total * 0.9
    return {"total": total, "discount": total < 0}

// стало
def calculate_total(items):
    total = 0
    for item in items:
        if item["price"] > 0 and item["count"] > 0:
            total += item["price"] * item["count"]
    return total

def apply_discount(total, threshold=1000, rate=0.1):
    if total > threshold:
        return total * (1 - rate)
    return total

def process(data):
    total = calculate_total(data["items"])
    final = apply_discount(total)
    return {"total": final, "discount": final < total}

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

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

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

Порядок работы на большом проекте и приоритеты

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

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

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

Третий критерий это стоимость понимания. Если новый сотрудник тратит день на разбор модуля, это измеримая потеря.

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

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

def priority(module):
    score = 0
    score += module["changes_per_month"] * 2
    score += module["bugs_last_quarter"] * 3
    score += module["onboarding_hours"] * 1
    score += 10 if module["critical"] else 0
    return score

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

Что происходит с архитектурой после перестройки

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

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

; пример правила: слой правил не зависит от инфраструктуры
[importlinter:contract:layers]
name = Слои приложения
type = layers
layers =
    api
    application
    domain

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

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

Четвёртое это документация решений. Записанное обоснование помогает не пересматривать один и тот же вопрос каждые полгода.

Ошибки при перестройке обходятся дороже, чем сам беспорядок

Разберём провалы, которые встречаются чаще всего.

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

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

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

Четвёртая ошибка это смешение перестройки с доработками. Правка поведения и правка структуры в одном изменении затрудняют понимание и откат.

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

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

Отдельный разбор касается границ между слоями и типичных нарушений

Полезно посмотреть, как выглядит нарушение на конкретных примерах.

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

Второе нарушение: сценарий применения знает о формате веб-запроса. Он принимает объект запроса и читает из него поля. Перенести тот же сценарий в командную строку уже не получится.

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

Четвёртое нарушение: сущность знает о способе сохранения. У объекта заказа есть метод, который пишет себя в таблицу. Проверка правила требует подготовки базы.

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

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

Как выглядит результат через полгода

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

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

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

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

Тесты быстрые, потому что не требуют базы и сети. Проверка правил занимает миллисекунды, и весь набор проходит за секунды.

Добавление новой возможности начинается с вопроса, к какому слою она относится. Ответ находится быстро, и правка затрагивает одно-два места.

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

Короткая памятка по началу работы

Первое: зафиксируйте текущее поведение тестами, даже если они выглядят грубо.

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

Третье: разбейте длинные функции на короткие, ничего не меняя по сути.

Четвёртое: вынесите обращения к внешнему миру за абстракцию.

Пятое: перенесите правила в отдельное место и убедитесь, что они не зависят от внешних библиотек.

Шестое: добавьте проверку правил зависимостей в сборку.

Седьмое: повторите для следующего модуля.

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

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