Factory Pattern у ICanUp з’явився всередині Communication Center.
На перший погляд створення діалогу виглядає як одна операція. Але Private chat, Group chat, Support request, Feedback і Post Discussion мають зовсім різні правила.
Приватний діалог може повторно використовувати вже наявну розмову між тими самими учасниками. Груповий діалог створює інший набір учасників і ролей. Support і Feedback працюють із даними гостей та призначеними відповідальними. Post Discussion прив’язаний до конкретного Post і має повторно використовувати вже наявне обговорення для того самого матеріалу.
Я не хотіла, щоб MessageService знав усі ці правила.
Без Factory MessageService мав би знати про всі типи Thread
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 не повинен знати про їхні внутрішні правила.
Спочатку резолвери отримали один контракт
interface ThreadResolverInterface{ public function resolve( ThreadContextData $context, ): Thread;}Factory має сенс лише тому, що код, який її викликає, може отримати будь-який resolver через спільний інтерфейс.
Усі реалізації приймають ThreadContextData і повертають Thread, але всередині можуть працювати зовсім по-різному.
Завдяки цьому Factory вибирає потрібну реалізацію, а код, який її використовує, більше не залежить від конкретного класу резолвера.
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 збирає відповідність типів і реалізацій в одному місці
$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 та спільним інтерфейсом і не знає про конкретні реалізації резолверів
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 показує, навіщо резолвер варто винести в окремий об’єкт
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 залишатися маленькою
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 |
|---|---|
Основна абстракція - взаємозамінний алгоритм. | Основна абстракція - вибір об’єкта, який потрібно повернути коду, що його викликає. |
|
|
Контекст працює через спільний контракт поведінки. | Код, що викликає Factory, передає тип і отримує потрібну реалізацію. |
Основний акцент - на виконанні алгоритму. | Основний акцент - на межі вибору та створення потрібної реалізації. |
Що зміниться, якщо з’явиться новий Thread type
Додати нове значення до
ThreadTypeEnum.Створити resolver, який реалізує
ThreadResolverInterface.Інкапсулювати в ньому правила пошуку або створення потрібного
Thread.Додати відповідність
type -> resolverуCommunicationServiceProvider.Додати тести для нового resolver-а та його доменних правил.
При цьому MessageService::create() не повинен отримувати новий case із логікою конкретного типу Thread.
Resolvers тестуються окремо від MessageService
$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 не намагається робити забагато. Вона прибирає логіку вибору з коду, який її викликає, а доменну поведінку залишає там, де їй і місце.



