Search
Write a publication
Pull to refresh

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

Level of difficultyEasy
Reading time10 min
Views932

Введение

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

Чаще всего это выглядит примерно так: вы увидели классный паттерн — например Builder, Factory или Adapter — и руки начинают чесаться его применить, но вот беда: конкретной задачи под него нет. И тогда рождаются архитектуры ради архитектуры: многоуровневые классы, билдеры для ошибок, фабрики для фабрик и другие инженерные шедевры, которые делают код не проще, а сложнее. Это то самое состояние «услышал звон, да не знаю, где он» — хочется применить новое знание, но понимания, зачем оно действительно нужно, пока нет.

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

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

Как распознать оверинжиниринг?

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

1. Избыточные абстракции

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

2. Паттерны ради паттернов

Это отдельный вид «зуда»: узнал о паттерне и хочется применить его где угодно. Builder, Factory, Adapter, Command — все в бой, даже если реальной задачи под них нет. В итоге код становится сложнее, а пользы от этого почти никакой. Это как пытаться есть суп палочками только потому, что «так моднее».

3. Классы ради классов

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

4. Подготовка к «возможному» будущему

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

Если замечаете такие вещи в проекте, скорее всего, у вас как раз тот самый случай оверинжиниринга.

Примеры из практики

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

💡 Все примеры условные, собирательные и не связаны с конкретными людьми или проектами — цель показать подход, а не кого-то пристыдить.

Пример 1. Вложенные классы вместо Enum

Как было:

class Report:
    class Story:
        USERS = "USERS"
        COURSES = "COURSES"
        ACCOUNTS = "ACCOUNTS"

    class Feature:
        USERS_SERVICE = "USERS_SERVICE"
        COURSES_SERVICE = "COURSES_SERVICE"
        ACCOUNTS_SERVICE = "ACCOUNTS_SERVICE"

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

Как стало:

from enum import StrEnum

class Story(StrEnum):
    USERS = "USERS"
    COURSES = "COURSES"
    ACCOUNTS = "ACCOUNTS"

class Feature(StrEnum):
    USERS_SERVICE = "USERS_SERVICE"
    COURSES_SERVICE = "COURSES_SERVICE"
    ACCOUNTS_SERVICE = "ACCOUNTS_SERVICE"

Теперь всё плоско, читается проще и при этом мы используем Enum, что явно сигнализирует: это набор фиксированных значений. Получение значения становится короче (Story.USERS), а код самодокументируемый.

Вывод: Не всегда нужно городить вложенные классы ради «красоты». Если вы просто храните набор констант — Enum решает задачу лучше и без лишней иерархии.

Пример 2. Фабрика ради фабрики

Как было:

from pydantic import BaseModel

class User(BaseModel):
    id: int
    email: str
    password: str

class ModelsFactory:
    @staticmethod
    def build_user(id: int, email: str, password: str) -> User:
        return User(id=id, email=email, password=password)

ModelsFactory.build_user(id=1, email="user@example.com", password="qwerty")

На первый взгляд, выглядит «архитектурно»: у нас есть отдельная фабрика, которая «строит» пользователей. Но если присмотреться — она просто вызывает конструктор модели и передаёт туда параметры без какой-либо дополнительной логики. Получается «обёртка ради обёртки».

Как стало:

from pydantic import BaseModel

class User(BaseModel):
    id: int
    email: str
    password: str

User(id=1, email="user@example.com", password="qwerty")

Всё то же самое, но без лишнего слоя. Мы просто создаём объект, не прячем это действие за искусственной «фабрикой».

Вывод: Фабрики полезны, когда они инкапсулируют сложную логику создания объекта (разные сценарии, дефолтные значения, зависимости). Но когда фабрика делает ровно то же самое, что и конструктор, это классический пример оверинжиниринга: кода больше, смысла — меньше.

Пример 3.1. Искусственные ветвления (UsersHTTPClient)

Как было:

class UsersHTTPClient:
    def __init__(self, env: str):
        ...

def build_client(env: str) -> UsersHTTPClient:
    if env == "DEV":
        return UsersHTTPClient(env="dev")
    elif env == "PROD":
        return UsersHTTPClient(env="prod")
    else:
        raise ValueError("Unknown env")

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

Как стало:

class UsersHTTPClient:
    def __init__(self, env: str):
        ...

def build_client(env: str) -> UsersHTTPClient:
    if env not in ("DEV", "PROD"):
        raise ValueError("Unknown env")

    return UsersHTTPClient(env=env.lower())

Вместо двух (а в будущем и трёх, и пяти) веток мы просто проверяем допустимые значения и преобразуем их к нужному виду. Код короче, проще и масштабируется без дополнительных elif.

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

Пример 3.2. Искусственные ветвления (ClientError)

Как было:

from pydantic import BaseModel

class ClientError(BaseModel):
    code: int
    type: str
    message: str

def build_client_error(type: str) -> ClientError:
    if type == "not_found":
        return ClientError(code=4, type="not-found", message="Not found!")
    elif type == "internal":
        return ClientError(code=11, type="internal", message="Internal!")
    else:
        return ClientError(code=13, type="unknown", message="Unknown!")

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

Как стало:

from pydantic import BaseModel

class ClientError(BaseModel):
    code: int
    type: str
    message: str

def build_not_found_client_error() -> ClientError:
    return ClientError(code=4, type="not-found", message="Not found!")

def build_internal_client_error() -> ClientError:
    return ClientError(code=11, type="internal", message="Internal!")

def build_unknown_client_error() -> ClientError:
    return ClientError(code=13, type="unknown", message="Unknown!")

Вместо одного метода с ветвлением — отдельные функции под конкретные сценарии. Такой код проще тестировать, проще читать и он явно выражает намерение: «хочу создать именно ошибку типа Not Found».

Вывод: Объединять всё в один метод кажется соблазнительным, но часто это ведёт к разрастанию ветвлений. Когда сценарии действительно разные и имеют свою логику, проще сделать для каждого отдельный конструктор или хелпер. Это делает код прямым и читаемым, а не «универсальным на бумаге».

Пример 4. Переусложнённый Page Object

Как было:

class CourseCard:
    def __init__(self, index: int, title: str, page: Page):
        self.page = page
        self.title = title
        self.index = index

    def check_visible(self):
        expect(self.page.locator("#course-card").nth(self.index)).to_be_visible()
        expect(self.page.locator("#course-card").nth(self.index)).to_have_text(self.title)


class CoursesPage:
    def __init__(self, page: Page):
        self.page = page

    def check_course_card_visible(self, title: str, index: int):
        card = CourseCard(page=self.page, index=index, title=title)
        card.check_visible()

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

Как стало:

class CourseCard:
    def __init__(self, page: Page):
        self.page = page
        self.card = page.locator("#course-card")

    def check_visible(self, index: int, title: str):
        expect(self.card.nth(index)).to_be_visible()
        expect(self.card.nth(index)).to_have_text(title)


class CoursesPage:
    def __init__(self, page: Page):
        self.page = page
        self.card = CourseCard(page=page)


courses_page = CoursesPage(page=Page())
courses_page.card.check_visible(index=1, title="Hello!")

Теперь структура проще: есть объект карточки, но он не требует хранения лишних свойств (index, title) внутри состояния. Мы передаём их в метод по факту использования. Это делает код компактнее и убирает избыточные конструкторы, которые использовались ради одного вызова.

Вывод: Page Object — полезный паттерн, но с ним легко переборщить. Если объект создаётся только ради одного метода и не хранит реального состояния — возможно, это просто лишний слой. Чем проще структура тестов, тем легче её поддерживать и объяснять новичкам.

Пример 5. Абстрактные интерфейсы без альтернатив

Как было:

from typing import Protocol

class Storage(Protocol):
    def save(self, data: str) -> None: ...

class FileStorage(Storage):
    def save(self, data: str) -> None:
        with open("file.txt", "w") as f:
            f.write(data)

На первый взгляд выглядит «по науке»: есть интерфейс Storage, есть конкретная реализация. Но если приглядеться, реализации всего одна, и другой в обозримом будущем не планируется. В итоге мы получаем лишний уровень абстракции, который никто не использует, но который нужно поддерживать и таскать по коду.

Как стало:

def save_to_file(data: str) -> None:
    with open("file.txt", "w") as f:
        f.write(data)

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

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

Пример 6. DTO поверх Pydantic-моделей

Как было:

from pydantic import BaseModel

class UserDTO(BaseModel):
    id: int
    email: str

class UserResponse(BaseModel):
    id: int
    email: str

def to_dto(user: UserResponse) -> UserDTO:
    return UserDTO(id=user.id, email=user.email)

На первый взгляд, выглядит аккуратно: отдельная DTO-модель для передачи данных, отдельная модель ответа. Но если задуматься — они идентичны. Мы фактически создаём дубликат модели без какой-либо дополнительной логики, просто ради «правильной архитектуры». В результате увеличивается количество кода, а ценности это не добавляет.

Как стало:

from pydantic import BaseModel

class UserResponse(BaseModel):
    id: int
    email: str

def to_dto(user: UserResponse) -> UserResponse:
    return user  # уже Pydantic, зачем дублировать?

Мы избавились от ненужной прослойки. Pydantic-модель и так выполняет роль DTO: валидирует данные, умеет сериализоваться и обеспечивает читаемую структуру.

Вывод: DTO полезны, когда требуется трансформация данных (например, скрыть поля, объединить данные из разных источников или изменить формат). Но если модель уже идеально отражает нужные данные, создавать «DTO ради DTO» — это типичный оверинжиниринг: кода больше, смысла меньше.

Пример 7. Сервисы с одним методом

Как было:

import hashlib

class PasswordService:
    @staticmethod
    def hash_password(password: str) -> str:
        return hashlib.sha256(password.encode()).hexdigest()

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

Как стало:

import hashlib

def hash_password(password: str) -> str:
    return hashlib.sha256(password.encode()).hexdigest()

Функция делает ровно то же самое, но код стал проще и понятнее: никакого класса, никакой «архитектурной обёртки».

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

Общий вывод

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

  • появляется больше кода, чем реально нужно,

  • новому человеку в проекте сложнее понять, что происходит,

  • изменения требуют больше усилий, чем могли бы.

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

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

Принципы, которые помогают избегать оверинжиниринга

1. KISS (Keep It Simple, Stupid)

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

2. YAGNI (You Aren’t Gonna Need It)

Не пишите код «на будущее», если нет конкретной задачи — YAGNI. Многие избыточные абстракции рождаются из желания «а вдруг потом понадобится». Чаще всего не понадобится. Делайте ровно то, что нужно здесь и сейчас, а если в будущем появится новая необходимость — добавить её будет проще на базе понятного и простого кода.

3. Читаемость важнее универсальности

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

4. Оптимизируйте только то, что реально повторяется

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

5. Явное лучше, чем неявное (Explicit is better than implicit)

Это правило из «Дзен Python» отлично подходит и для борьбы с оверинжинирингом. Когда код очевиден и читается буквально как инструкция, его проще понять, поддерживать и изменять. Излишние абстракции часто делают код «умнее, чем он есть на самом деле»: приходится разбираться в скрытых связях, динамических вызовах и «магии». Лучше написать простой и прямой вызов, чем заставлять коллег догадываться, как оно всё работает «под капотом».

Заключение

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

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

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

Tags:
Hubs:
+4
Comments5

Articles