Разделение функций на команды и запросы

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

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

Что за команды и запросы такие?

Запрос — функция, которая возвращает результат и не имеет сайд-эффектов. Например:

const isEqual = (a, b) => a === b;

class DeepThought {
	answerTheUltimateQuestionOfLifeTheUniverseAndEverything() {
		return 42;
	}
}

Команда — функция, которая меняет состояние системы и ничего не возвращает. Пример команды, которая увеличивает значение счётчика:

const state = { counter: 0 };
const increaseCounter = () => {
	state.counter++;
};

// или классом:
class Counter {
	constructor() {
		this.state = 0;
	}

	increase() {
		this.state++;
	}
}

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

Понятно, что происходит

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

// новый рейтинг, айдишник пользователя, что-то ещё?
const updated = userAccount.updateRating(10);

Да и в принципе из названия функции updateRating не очевидно, что она вообще может что-то вернуть. Если мы хотим, чтобы название было говорящим, то придётся написать нечто вроде:

// ну... видимо, тут рейтинг возвращается? хотя хз, всё равно проверить надо
const updatedRating = userAccount.updateRatingAndGetUpdatedValue(10);

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

// команда для обновления рейтинга в сервисе
userAccount.updateRating(10);

// запрос для получения нового значения рейтинга
const updatedRating = userAccount.getRating();

SRP автоматом

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

Допустим, вот сложная функция-солянка updateSubscriptions. Она принимает на вход массив тегов, на которые подписывается пользователь, создаёт объект подписки, проверяет доступна ли она для текущего пользователя, активен ли пользователь, добавляет подписку в массив, обновляет рейтинг:

class UserAccount {
	constructor(userAccountInfo) {
		const { rating = 0, active = false, subscriptions = [] } = userAccountInfo;

		this.subscriptions = subscriptions;
		this.active = active;
		this.rating = rating;
	}

	_createLabel(tag) {
		return `Label for ${tag}`;
	}

	_defineType(tag) {
		return tag.includes('secret_code') ? TYPES.PREMIUM : TYPES.REGULAR;
	}

	isSubscriptionValid(subscription) {
		// сложная логика валидации
		return true;
	}

	updateSubscriptions(tags) {
		tags.forEach((tag) => {
			const label = this._createLabel(tag);
			const type = this._defineType(tag);
			const subscription = { label, type };

			if (this.isSubscriptionValid(subscription) && this.active) {
				this.subscriptions.push(subscription);

				if (type === TYPES.PREMIUM) this.rating += 5;
				else this.rating++;
			}
		});
	}
}

Упростим, разбив функции на команды и запросы.

class UserAccount {
	// ...конструктор и старые функции

	// запрос: нет сайд-эффектов, возвращаем объект подписки
	_createSubscription(tag) {
		const label = this._createLabel(tag);
		const type = this._defineType(tag);
		return { label, type };
	}

	// тоже запрос
	isUserActive() {
		return this.active;
	}

	// команда: возвращаемого результата нет,
	// изменяется состояние поля subscriptions
	appendSubscription(subscription) {
		this.subscriptions.push(subscription);
	}

	// тоже команда
	increaseRatingBy(delta) {
		this.rating += delta;
	}

	// снова запрос: возвращаем количество баллов, не меняя состояния системы
	_calcRatingDeltaForSubscription(subscription) {
		if (subscription.type === TYPES.PREMIUM) return 5;
		return 1;
	}

	updateSubscriptions(tags) {
		// увидели, что можно сделать ранний выход
		if (!this.isUserActive()) return;

		tags.forEach((tag) => {
			// созданием подписки теперь занимается запрос;
			// мы не привязываемся к структуре данных подписок;
			// стало видно, что работу с подписками неплохо бы вынести в отдельный класс
			const subscription = this._createSubscription(tag);
			if (!this.isSubscriptionValid(subscription)) return;

			// добавление подписки не привязано к структуре данных;
			// мы можем заменить массив на стек, и достаточно будет подправить
			// только appendSubscription, чтобы функциональность осталась рабочей
			this.appendSubscription(subscription);

			// здесь теперь нет магических чисел;
			// подсчёт изменения рейтинга находится в одном месте
			const ratingDelta = this._calcRatingDeltaForSubscription(subscription);

			// обновление рейтинга называется адекватно;
			// стало ясно, что эта функция делает
			this.increaseRatingBy(ratingDelta);
		});
	}
}

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

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

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

Удобнее тестировать

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

Это, конечно, скорее заслуга SRP, но тем не менее.

Раздельная масштабируемость

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

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

Правда иногда это оверкил

Есть ситуации, когда такое разделение излишне, или когда просто смешивать логику удобнее. Допустим вот сервис, который регистрирует нового пользователя:

const userId = remoteUserApi.signup({ login, password });

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

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

Ссылки по теме

Книги:

И остальное: