0% прочитано

Factory Pattern у Laravel: як ThreadType обирає правильний Conversation resolver

Communication Center у ICanUp підтримує Private, Group, Support, Feedback і Post Discussion threads, але кожен із цих типів створюється за власними правилами. У статті я показую, як `ThreadResolverFactory` вибирає потрібний resolver за `ThreadTypeEnum`, а `MessageService` при цьому залишається незалежним від конкретних реалізацій.

28 вересня 2026 р. 10 хв читанняЛаравель

Factory Pattern у ICanUp з’явився всередині Communication Center.

На перший погляд створення діалогу виглядає як одна операція. Але Private chat, Group chat, Support request, Feedback і Post Discussion мають зовсім різні правила.

Приватний діалог може повторно використовувати вже наявну розмову між тими самими учасниками. Груповий діалог створює інший набір учасників і ролей. Support і Feedback працюють із даними гостей та призначеними відповідальними. Post Discussion прив’язаний до конкретного Post і має повторно використовувати вже наявне обговорення для того самого матеріалу.

Я не хотіла, щоб MessageService знав усі ці правила.

У цьому випадку Factory не створює Thread безпосередньо. Вона лише вибирає об’єкт, який знає, як знайти або створити потрібний Thread. Це невелика Simple Factory, а не Factory Method, побудований на ієрархії підкласів.

Без Factory MessageService мав би знати про всі типи Thread

PHP - MessageService owns resolver selection
public function create(    CreateMessageData $data,): Message {    $thread = match (        $data->threadContext->type    ) {        ThreadTypeEnum::PRIVATE =>            $this->privateResolver                ->resolve(                    $data->threadContext                ),         ThreadTypeEnum::GROUP =>            $this->groupResolver                ->resolve(                    $data->threadContext

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

Ще гірше, конструктор MessageService мав би окремо отримувати всі конкретні резолвери.

У результаті створення повідомлення стало б залежати від повного переліку типів розмов, хоча саме MessageService не повинен знати про їхні внутрішні правила.

Спочатку резолвери отримали один контракт

PHP - One contract for all Thread resolvers
interface ThreadResolverInterface{    public function resolve(        ThreadContextData $context,    ): Thread;}

Factory має сенс лише тому, що код, який її викликає, може отримати будь-який resolver через спільний інтерфейс.

Усі реалізації приймають ThreadContextData і повертають Thread, але всередині можуть працювати зовсім по-різному.

Завдяки цьому Factory вибирає потрібну реалізацію, а код, який її використовує, більше не залежить від конкретного класу резолвера.

ThreadResolverFactory має одну маленьку відповідальність

PHP - Core shape of ThreadResolverFactory
final readonly class ThreadResolverFactory{    /**     * @param array<string, ThreadResolverInterface> $resolvers     */    public function __construct(        private array $resolvers,    ) {}     public function make(        ThreadTypeEnum $type,    ): ThreadResolverInterface {        return $this->resolvers[            $type->value        ];    }

Суть Factory дуже проста: отримати ThreadTypeEnum і повернути відповідний ThreadResolverInterface.

У робочій реалізації також можна додати перевірку для випадку, коли певному типу не відповідає жоден resolver. Але головна архітектурна межа полягає не у винятку, а в тому, що відповідність «тип Thread - конкретний resolver» ізольована від MessageService.

Контейнер Laravel збирає відповідність типів і реалізацій в одному місці

PHP - Map Thread types to resolver implementations
$this->app->singleton(    ThreadResolverFactory::class,    static fn ($app) =>        new ThreadResolverFactory([            ThreadTypeEnum::PRIVATE->value =>                $app->make(                    PrivateThreadResolver::class                ),             ThreadTypeEnum::GROUP->value =>                $app->make(                    GroupThreadResolver::class                ),             ThreadTypeEnum::SUPPORT->value =>                $app->make(

Конкретні класи все одно мають бути відомі в якомусь одному місці. У ICanUp цю роль виконує CommunicationServiceProvider.

Тут контейнер Laravel збирає відповідність ThreadTypeEnum value -> resolver object. Доменний сервіс отримує вже готову Factory і не займається складанням залежностей.

Коли з’являється новий resolver, змінюється лише одна точка конфігурації.

MessageService працює з Factory та спільним інтерфейсом і не знає про конкретні реалізації резолверів

PHP - Factory selection inside message creation
public function create(    CreateMessageData $data,): Message {    return DB::transaction(        function () use ($data): Message {            $resolver =                $this->threadResolverFactory                    ->make(                        $data                            ->threadContext                            ->type                    );             $thread =                $resolver->resolve(                    $data->threadContext

Саме цей фрагмент коду найкраще показує для мене цінність Factory.

MessageService знає лише, що йому потрібен Thread. Він передає тип у Factory, отримує відповідний resolver і викликає спільний метод resolve().

Йому не потрібно знати, чи Private resolver шукає вже наявну розмову, чи Support resolver додає відповідальних, чи Post Discussion працює з конкретним об’єктом обговорення.

Factory приховує вибір, але не стирає різницю між резолверами

Resolver

Власна відповідальність

PrivateThreadResolver

Працює з приватними розмовами та їхніми учасниками й може повторно використовувати вже наявний приватний Thread між тими самими користувачами.

GroupThreadResolver

Створює групову розмову, призначає ролі owner/member і нормалізує список учасників.

SupportThreadResolver

Створює Support Thread, підтримує контекст гостя та додає користувачів із правом відповідати на звернення як відповідальних.

FeedbackThreadResolver

Створює Feedback Thread, працює з даними гостя та призначає відповідальних за feedback.

PostDiscussionThreadResolver

Вимагає Post як об’єкт обговорення, створює публічну дискусію та повторно використовує Thread для того самого Post.

Post Discussion показує, навіщо резолвер варто винести в окремий об’єкт

PHP - Post Discussion has its own creation rules
public function resolve(    ThreadContextData $context,): Thread {    return DB::transaction(        static function () use (            $context,        ): Thread {            $subject =                $context->subject();             if ($subject === null) {                throw new InvalidArgumentException(                    __(                        'validation.communication.'                        .'post_discussion.'                        .'subject_required'

Це вже не просто операція «створити Thread певного типу». Resolver перевіряє об’єкт обговорення, формує ідентичність дискусії через type + subject_type + subject_id, використовує firstOrCreate() і визначає власні правила видимості.

Якби вся ця логіка жила всередині універсальної Factory, вона дуже швидко перетворилася б на ще один великий сервіс із окремими гілками для кожного типу Thread.

Тому Factory лише вибирає потрібний resolver, а доменна поведінка залишається всередині самого резолвера.

ThreadContextData дозволяє Factory залишатися маленькою

PHP - One context object for resolver input
final readonly class ThreadContextData{    public function __construct(        public ThreadTypeEnum $type,        public ?Model $subject = null,        public ?int $createdById = null,        public ?string $guestName = null,        public ?string $guestEmail = null,        public ?string $guestPhone = null,        public bool $preserveByEmail = false,        public ?int $parentMessageId = null,        public ?ThreadSourceContextData $sourceContext = null,        public array $participantUserIds = [],    ) {}}

Factory не отримує десяток параметрів і не вирішує, які з них потрібні конкретному резолверу.

Для вибору реалізації їй достатньо лише type. Повний контекст передається вже вибраному резолверу, а він сам використовує тільки ті дані, які потрібні для його сценарію.

Це особливо зручно, коли різні сценарії створення Thread працюють із даними гостя, об’єктом обговорення, джерелом звернення або ідентифікаторами учасників у різних комбінаціях.

Чим Factory відрізняється від Strategy з попередньої статті

Strategy

Factory

Основна абстракція - взаємозамінний алгоритм.

Основна абстракція - вибір об’єкта, який потрібно повернути коду, що його викликає.

SearchProvider сам реалізує потрібну поведінку стратегії.

ThreadResolverFactory повертає відповідний resolver.

Контекст працює через спільний контракт поведінки.

Код, що викликає Factory, передає тип і отримує потрібну реалізацію.

Основний акцент - на виконанні алгоритму.

Основний акцент - на межі вибору та створення потрібної реалізації.

Тут патерни частково перетинаються: resolvers теж реалізують взаємозамінну поведінку. Але конкретна роль `ThreadResolverFactory` полягає саме у виборі потрібної реалізації. Саме тому цей приклад добре підходить для окремої статті про Factory Pattern.

Що зміниться, якщо з’явиться новий Thread type

  1. Додати нове значення до ThreadTypeEnum.

  2. Створити resolver, який реалізує ThreadResolverInterface.

  3. Інкапсулювати в ньому правила пошуку або створення потрібного Thread.

  4. Додати відповідність type -> resolver у CommunicationServiceProvider.

  5. Додати тести для нового resolver-а та його доменних правил.

При цьому MessageService::create() не повинен отримувати новий case із логікою конкретного типу Thread.

Resolvers тестуються окремо від MessageService

PHP - Test resolver-specific invariants directly
$resolver = app(    PostDiscussionThreadResolver::class); $firstThread = $resolver->resolve(    new ThreadContextData(        type:            ThreadTypeEnum::POST_DISCUSSION,         subject:            $post,         createdById:            $firstUser->id,    ));

Межа Factory також спрощує тестування. Немає потреби перевіряти всі правила роботи з Thread через створення Message.

PrivateThreadResolver окремо перевіряє повторне використання розмови та роботу з учасниками. GroupThreadResolver - ролі й відсутність дублікатів. SupportThreadResolver і FeedbackThreadResolver - дані гостей та призначення відповідальних. PostDiscussionThreadResolver - об’єкт обговорення, його доступність і повторне використання вже наявної дискусії.

А точка інтеграції з MessageService при цьому залишається дуже маленькою.

Коли Factory був би зайвим

Якби система мала лише один тип Thread, ThreadResolverFactory не давала б практичної користі. MessageService міг би напряму залежати від одного resolver-а.

Factory стала корисною тоді, коли код, який її викликає, залишився один, контракт теж залишився спільним, але сценарії створення Thread стали різними.

Водночас важливо не переносити доменну логіку всередину Factory. Якщо make() починає створювати учасників, перевіряти видимість Post або працювати з даними гостя, межа відповідальності знову починає розмиватися.

Хороша Factory вирішує, хто має виконати роботу. Вона не повинна забирати цю роботу собі.

Висновок

У ICanUp ThreadResolverFactory дуже маленька, і саме в цьому її користь.

Вона ізолює відповідність між ThreadTypeEnum і конкретним resolver-ом. MessageService не знає про всі типи розмов, а кожен resolver сам відповідає за правила свого сценарію роботи з Thread.

Private, Group, Support, Feedback і Post Discussion можуть розвиватися незалежно, тоді як точка інтеграції залишається незмінною: спочатку make(type), потім resolve(context).

Для мене це хороший приклад Factory Pattern саме тому, що Factory не намагається робити забагато. Вона прибирає логіку вибору з коду, який її викликає, а доменну поведінку залишає там, де їй і місце.