За 9 лет разработки ПО  я периодически выступал в  роли ментора и сталкивался с проблемой, которую недавно озвучил начинающий программист после онлайн-курсов:

«Не понимаю, как делить код на классы».

Оказалось, на курсах учили языку, но не программированию. А ведь язык — лишь инструмент, и принципы проектирования кода универсальны для разных языков программирования.

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

Так и родилась идея написать статью «Шаблоны и принципы деления кода на классы».

Типы классов и шаблоны

Все классы я делю на 3 основных типа:

  1. Дата-класс

  2. Класс-управленец

  3. Класс-исполнитель

Дата-класс

Самый простой тип класса. Его объекты статичны — они ничего не делают в программе, все действия происходят над самим объектом.

Назначение:

хранить данные

Аналогия
из жизни:

ящик с предметами

Характеристики:

- отсутствует бизнес-логика, зависимости от других классов и взаимодействие с внешними системами;

- в полях классах хранятся данные;

- методы класса — это setter'ы и getter'ы, иногда вспомогательные методы для работы с данными внутри такого класса.

Популярные шаблоны

Data Access Object (DAO)

Дата-класс, описывающий схему хранилища данных (БД). Упрощает работу с БД, особенно в ORM–фреймворках, и отделяет бизнес-модели данных от моделей данных БД.

Data Transfer Object (DTO)

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

Value Object

Дата-класс, состояние которого не меняется после создания.

Класс-управленец

Класс, который координирует выполнение действий в программе.

Назначение:

- описывать бизнес-процессы или алгоритмы на языке программирования; 

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

Аналогия
из жизни:

конвейер

Характеристики:

- хранит ссылки на объекты классов-исполнителей и(или) на другие классы-управленцы;

- не хранит никакие данные;

- ничего не выполняет сам, только управляет процессом через делегирование.

Популярные шаблоны

Controller

Класс-управленец с несколькими точками входа, где каждая запускает отдельную функцию. «Запускает функцию» значит, что класс делегирует выполнение другим объектам, обычно сервисам.

Service

Класс-управленец, описывающий набор операций (процессов, алгоритмов) в рамках одного домена. Не делает действия сам, а делегирует их классам-исполнителям.

Pipeline

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

Класс-исполнитель

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

Назначение:

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

Аналогия
из жизни:

почтальон

Характеристики:

- может хранить данные в полях класса, необходимые для выполнения работы;

- может хранить ссылки на другие классы-исполнители;

- может взаимодействовать с внешними системами;

- выполняет реальные действия в программе.

Популярные шаблоны

Класс-утилита

Класс-исполнитель с множеством методов, объединённых одним доменом. Не привязан к конкретному классу и может использоваться несколькими классами.

Класс-помощник (класс-компаньон)

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

Lambda-объект

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

Схема типов классов

Этих трёх типов достаточно для организации программы любой сложности.

(дополнительно) Объединение типов (классы-гибриды)

Когда есть разделение на типы, то рано или поздно возникает вопрос: :

а можно ли их объединять?

Отвечу так:

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

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

Пример хорошего объединения типов

Класс Result

Назначение:

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

Какие типы объединяет:

Дата-класс (Value Object) и Класс-исполнитель (Класс-компаньон)

Х��рактеристики:

- не меняет своё состояние после создания (Value Object)

- выполняет определённые действия над дата-классом и привязан только к нему (Класс-компаньон)

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

Принципы деления кода на классы

К текущему моменту я описал типы классов, несколько популярных шаблонов и немного раскрыл тему объединения типов классов. Теперь настало время поговорить о принципах. Их всего два:

  1. Деление по домену

  2. Деление по роли

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

И есть два важных (!) момента, без которых деление корректно работать не будет:

  • принципы деления нужно применять всегда при создании нового класса и определении его назначения;

  • нужно применять оба принципа вместе, а не по отдельности.

Деление по домену

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

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

Но не смотря на это, чаще всего деление по домену — это деление по классу (типу) предмета. Например, вынести в отдельный класс весь код, который работает только с сущностью Customer. Или например, вынести весь код в отдельный класс, который отправляет REST-запросы, а в другой класс — SOAP-запросы.

Вооружившись этим принципом, уже можно делить код на классы и организовывать структуру проекта. Но есть проблема...

Деление по домену — это деление большими кусками.

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

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

Как же тогда уменьшить объём кода в классе после деления по домену?

Дать новому классу специализацию, другими словами, определить его роль.

Деление по роли

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

У класса должна быть только одна роль, максимально специфичная. Соблюдение этого правила означает следование принципу единственной ответственности (Single Responsibility Principle).

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

Приведу пример роли для класса. Возьму роль Client. С разными доменами можно создать множество классов: RestClient, SoapClient, Repository (тоже Client, только для работы с БД). Другими словами, роли могут повторяться в разных доменах, как в RestClient и SoapClient, где Rest, Soap — домены, а Client — роль.

Пример

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

Представим проект:

Веб-сервис (веб-приложение), позволяющий пользователю выполнять математические операции.

Пользовательский сценарий:
  1. Пользователь заходит на сайт.

  2. Выбирает из списка математическую операцию.

  3. Заполняет необходимые поля.

  4. Нажимает кнопку «Выполнить».

Ожидаемый результат:

  • в поле «Результат» отобразится текст с результатом операции.

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

Теперь представим задачу:

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

(Задача специально упрощена, чтобы сосредоточиться на разделении кода по классам, а не на деталях реализации)

Коддинг

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

Первая версия кода (по структуре) обычно выглядит так:

public class MathService {

    public String multiply(String strNumber1, String strNumber2) {
        int num1 = Integer.parseInt(strNumber1);
        int num2 = Integer.parseInt(strNumber2);
        int result = num1 * num2;
        String strResult = new Integer(result).toString();
        return strResult;
    }
}

Как образовался этот класс?

Разработчик взял домен основной бизнес-операции веб-приложения и обозначил его словом Math, затем назначил ему роль Service.

Выделение кода в класс произошло, как только определились домен и роль будущего класса.  Казалось бы, отличная реализация —  код выглядит лаконично  и просто. Но опытные разработчики могут не согласиться. Почему? Попробуем определить тип класса.

Какой тип у получившегося класса?

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

Почему объединение типов класс-управленец и класс-исполнитель не самое удачное?

Объединив эти два типа, вы получаете играющего тренера, который и руководит игроками, и сам играет. Другими словами, класс получает две роли.

А в принципе «Деление по роли», я делал важное уточнение:

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

В приведённом примере разработчик взял слишком обобщённую роль — веб-сервис. Именно этот смысл заключён в слове Service в названии класса. Поэтому текущий класс следовало бы переименовать в MathWebService, чтобы точнее обозначить его домен и роль:

public class MathWebService {

    public String multiply(String strNumber1, String strNumber2) {
        int num1 = Integer.parseInt(strNumber1);
        int num2 = Integer.parseInt(strNumber2);
        int result = num1 * num2;
        String strResult = new Integer(result).toString();
        return strResult;
    }
}

Какие роли у класса MathWebService?

Если смотреть на реализацию метода multiply, то можно увидеть, что метод делает два вида операций:

  • парсинг строк в числа и обратно;

  • выполнение математической операции.

Эти две операции — роли классов-исполнителей. Однако разработчики часто упускают другую роль, которая неявно присутствует в этом методе — процесс (алгоритм) выполнения операции multiplyс точки зрен��я веб-сервиса:

  1. распарсить входные данные;

  2. выполнить математическую операцию умножения;

  3. преобразовать результат в строку;

  4. вернуть ответ в виде строки.

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

У каждого процесса (алгоритма) есть шаги. Каждый шаг с точки зрения класса-управленца всегда отвечает на вопрос «Что делать?», но не включает в себя информацию «Как делать?». Перечитайте шаги процесса операции multiply и задайте к каждому пункту эти два вопроса: «Что делать?», «Как делать?».

Ни один пункт процесса не отвечает на вопрос «Как делать?» — так и должно быть. Но тогда возникает вопрос: кто же будет отвечать на вопрос «Как делать?»

Ответственность за «Как делать» лежит на  классе-исполнителе.

Таким образом, у класса MathWebService фактически три роли:

  1. описание процесса (алгоритма) работы метода выполнения пользовательского запроса (Управленец);

  2. реализация шага процесса: парсинг (Исполнитель);

  3. реализация шага процесса: математическая операция (Исполнитель).

Как же тогда поделить MathWebService на классы?

Самую сложную задачу мы уже выполнили: выделили три специфичные роли. Обычно с определением домена сложностей не возникает — чаще проблема в определении роли класса и его зоны ответственности.

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

Первое, что сделаю: выделю операцию конвертации «строка -> число, число -> строка» в два отдельных метода:

public class MathWebService {

    public String multiply(String strNumber1, String strNumber2) {
        int num1 = toInt(strNumber1);
        int num2 = toInt(strNumber2);
        int result = num1 * num2;
        String strResult = toString(result);
        return strResult;
    }

    private int toInt(String strNumber) {
        return Integer.parseInt(strNumber);
    }

    private int toString(Integer number) {
        return number.toString();
    }
}

Напомню, что я определил тип класса MathWebService как класс-управленец, где каждый метод — это название процесса. Методы toInt(String) и toString(Integer) не являются процессами веб-сервиса, они делают определённую работу внутри этих процессов (в данном случае, операции multiply), а значит они должны относиться к классу-исполнителю. Поэтому пора выделить их в отдельный класс. Согласно принципам, при создании класса нужно сделать два действия: определить домен и роль. Я определил их так: домен — «работа с числами», роль «конвертер».

дополнительное пояснение

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

Поэтому класс назову NumberConverter. Вот так выглядит код:

public class NumberConverter {

    public int toInt(String strNumber) {
        return Integer.parseInt(strNumber);
    }

    public int toString(Integer number) {
        return number.toString();
    }
}

NumberConverter — типичный класс-исполнитель. Теперь добавлю экземпляр класса NumberConverter в MathWebService и перепишу его код так, чтобы он делегировал выполнение новому классу.

public class MathWebService {
    private final NumberConverter numberConverter = new NumberConverter(); // для простоты DI не буду использовать в примере.

    public String multiply(String strNumber1, String strNumber2) {
        int num1 = numberConverter.toInt(strNumber1);
        int num2 = numberConverter.toInt(strNumber2);
        int result = num1 * num2;
        String strResult = numberConverter.toString(result);
        return strResult;
    }
}

Теперь класс MathWebService полностью делегирует задачи парсинга классу-исполнителю NumberConverter. Осталось делегировать выполнение операции умножения. Буду действовать по уже проверенной схеме: вынести в отдельный метод, а затем —  в отдельный класс.

public class MathWebService {
    private final NumberConverter numberConverter = new NumberConverter(); // для простоты DI не буду использовать в примере.

    public String multiply(String strNumber1, String strNumber2) {
        int num1 = numberConverter.toInt(strNumber1);
        int num2 = numberConverter.toInt(strNumber2);
        int result = multiply(num1, num2);
        String strResult = numberConverter.toString(result);
        return strResult;
    }

    private int multiply(int num1, int num2) {
        return num1 * num2;
    }
}

Хотя имя метода multiply(int, int) совпадает с методом multiply(String, String), в Java это разные методы из-за разных типов аргументов. Чтобы не путаться, можно переименовать multiply(String, String), например в multiplyFeature(String, String):

public class MathWebService {
    private final NumberConverter numberConverter = new NumberConverter(); // для простоты DI не буду использовать в примере.

    public String multiplyFeature(String strNumber1, String strNumber2) {
        int num1 = numberConverter.toInt(strNumber1);
        int num2 = numberConverter.toInt(strNumber2);
        int result = multiply(num1, num2);
        String strResult = numberConverter.toString(result);
        return strResult;
    }

    private int multiply(int num1, int num2) {
        return num1 * num2;
    }
}

Теперь вынесу метод multiply(int, int) в отдельный класс. Определю его домен как «математика», и роль как «операции», поэтому назову класс MathOperations:

public class MathOperations {

    public int multiply(int num1, int num2) {
        return num1 * num2;
    }
}

MathOperations — типичный класс-исполнитель. Теперь добавлю экземпляр класса MathOperations в MathWebService и перепишу код так, чтобы он делегировал выполнение новому классу.

public class MathWebService {
    private final NumberConverter numberConverter = new NumberConverter(); // для простоты DI не буду использовать в примере.
    private final MathOperations mathOperations = new MathOperations(); // для простоты DI не буду использовать в примере.

    public String multiplyFeature(String strNumber1, String strNumber2) {
        int num1 = numberConverter.toInt(strNumber1);
        int num2 = numberConverter.toInt(strNumber2);
        int result = mathOperations.multiply(num1, num2);
        String strResult = numberConverter.toString(result);
        return strResult;
    }
}

Теперь MathWebService стал полноценным классом-управленцем. Он описывает процесс выполнения фичи, но не содержит деталей реализации,  и делегируя выполнение шагов классам-исполнителям. Согласно типизации, MathWebService соответствует шаблону Сервис, а NumberConverter и MathOperations следуют шаблону Класс-утилита.

На этом пример деления кода на классы завершён.

Кто-то возразит: зачем создавать столько классов ради 4 строк кода в методе?!

(дополнительно) мотивация на классовое деление

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

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

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

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

Поэтому логичнее создать новый класс. Я определил для него домен «веса», а роль «сервис конвертации величин». Так класс и назвал MeasureConverterWebService.

public class MeasureConverterWebService {
    private final NumberConverter numberConverter = new NumberConverter(); // для простоты DI не буду использовать в примере.
    private final MathOperations mathOperations = new MathOperations(); // для простоты DI не буду использовать в примере.

    public String kilosToGramFeature(String strKilos) {
        int kilos = numberConverter.toInt(strKilos);

        int result = mathOperations.multiply(kilos, 1000);

        String strResult = numberConverter.toString(result);
        return strResult;
    }
}

Обратите внимание, что для создания MeasureConverterWebService в проекте уже были все необходимые компоненты: NumberConverter и MathOperations. Разработчик собрал метод kilosToGramFeature из имеющегося кода, как конструктор. Более того, к моменту написания этого метода код NumberConverter и MathOperations уже был протестирован и есть уверенность в его работоспособности. А любой новый код ещё нужно протестировать и проверить реальными пользователями.

Заключение

В заключение подсвечу основные моменты:

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

  • Дата-класс нужен для обмена данными между классами и внешним миром, класс-управленец — для описания процесса (алгоритма) фичи в программе, класс-исполнитель — для выполнения реальных действий.

  • С помощью трёх типов классов можно организовать хорошую структуру программы любой сложности.

  • Принципы деления кода на классы рекомендую использовать всегда при создании нового класса.

важно на заметку

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

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

Возможно кто-то удивится, что я не упомянул в статье принципы объектно-ориентированного программирования (ООП), DRY или SOLID. Это важные принципы, но у них немного другое назначение, хотя они тоже могут быть связаны с делением кода на классы. Кто-то, посмотрев на пример, может кинуться в меня принципами KISS или YAGNI. Что ж скажу... это очень дискуссионная тема, и вы можете написать на неё свою статью, а моя на этом заканчивается.

Всем доброго времени суток.