ВЫЗВАЛ И ЗАБЫЛ
В прошлой статье я разобрал что такое cross-cutting concerns — системы, которые пересекают всю кодовую базу, но не являются бизнес-логикой. Сегодня — как с ними работать на практике.
🔸Что такое "call and forget"
Cross-cutting concerns — общепринятый термин. "Call and forget" — то, как я называю эти системы на практике.
Название описывает паттерн использования:
— Вызывающий код не ожидает возвращаемого значения
— Внутреннее состояние системы не влияет на бизнес-логику вызывающего
Примеры из каждого проекта:
▫️ Логирование — записал событие, пошел дальше
▫️ Аналитика — отправил трек, не ждешь ответа
▫️ Звуковая система — запустил звук, геймплей не зависит от результата
▫️ Вибрации — тактильная обратная связь, fire and forget в чистом виде
▫️ Toast'ы / Hint'ы — показал уведомление, логика продолжается
Ключевое свойство: убери любую из этих систем — и геймплей не изменится. Они ортогональны бизнес-логике.
🔸Почему singleton
Обслуживающий код занимает большую часть систем и "размывает" логику, которую мы хотим править.
Архитектурно я хочу чтобы на это тратилось как можно меньше места в рабочих классах.
Потому надо выбрать самый компактный способ связать классы друг с другом. И таковым является singleton.
Т.е. часть систем в каждом проекте можно спокойно сделать как SomeSystem.Instance.DoSmth — и проект от этого только выиграет. Но с правилами.
🔸Когда singleton — проблема
Проблема не в подходе, а ЧТО через него делают. Возможность вызвать бизнес-логику через глобальный доступ создает хаос связей.
Любой скрипт — визуальный эффект, UI-элемент — может напрямую командовать Game, Player, Economy.
Дочерние элементы обращаются к головным, образуя неконтролируемый граф зависимостей.
Но "call and forget" системы по определению не возвращают управление в бизнес-логику. Связь — однонаправленная.
А значит и проблем не будет, если правильно "готовить" работу с такими системами.
🔸Рецепт приготовления signleton
🔹Ингредиент 1: Явный lifecycle
Singleton создается и уничтожается в выделенных фазах — там же где регистрируются зависимости в DI-контейнере.
Никаких
get { _instance = new Class(); return _instance; }
Рядом с
diContainer.Register<IMyService>().AsSingle()
ты так же явно прописываешь
AudioService.Instance = new AudioService().
Один файл, одно место, полный контроль.
Я подробно разбирал Composition Root и фазы RRR в статье про DI, а проблемы с порядком инициализации — в статье про точку входа.
🔹Ингредиент 2: Доступ через интерфейс
Singleton отдает не конкретный класс, а интерфейс. Т.е. публичное свойство IAudioService, а не AudioService.
Первая причина — тестируемость. Можешь подставить заглушку в тестах.
Вторая — подмена по окружению.
Для dev-окружения аналитику заменяешь на логирование, звук в тестах — на NullAudioService, вибрации на десктопе — на StubHaptic.
🔻 Архитектурные решения — это всегда компромисс. А если тебе кажется что компромисса нет, значит ты его ещё не нашел. Вот так использование не самого любимого и проблемного паттерна может помочь замедлить рост сложности проекта.
Ставь 👍 если тебе заходит такого рода контент!
Ты знаешь кому переслать эту статью 💪
#решения@UniArchitect