The Factory Pattern in ICanUp appeared inside the Communication Center.
At first, creating a conversation thread looks like one operation. In practice, Private chat, Group chat, Support requests, Feedback, and Post Discussion follow very different rules.
A Private thread can reuse a conversation between the same participants. A Group thread creates a different participant structure. Support and Feedback work with guest data and assignees. Post Discussion is attached to a specific Post and should reuse the discussion for the same subject.
I did not want MessageService to understand all of those rules.
Without the Factory MessageService Would Know Every Thread Type
public function create( CreateMessageData $data,): Message { $thread = match ( $data->threadContext->type ) { ThreadTypeEnum::PRIVATE => $this->privateResolver ->resolve( $data->threadContext ), ThreadTypeEnum::GROUP => $this->groupResolver ->resolve( $data->threadContextThis code would work. But every new Thread type would modify a service whose real responsibility is creating Messages.
The constructor of MessageService would also need every concrete resolver as a separate dependency.
Message creation would become coupled to the complete catalog of conversation types.
First the Resolvers Received One Contract
interface ThreadResolverInterface{ public function resolve( ThreadContextData $context, ): Thread;}The Factory is useful because the caller can receive any resolver through one interface.
Every implementation accepts ThreadContextData and returns a Thread, while its internal behavior can be completely different.
The Factory selects the implementation and the caller no longer needs to know its concrete class.
ThreadResolverFactory Has One Small Responsibility
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 ]; }The core of the factory is simple: receive a ThreadTypeEnum and return the corresponding ThreadResolverInterface.
A production implementation can also guard against unsupported mappings. The architectural point is not the exception but that type-to-resolver lookup is isolated from MessageService.
The Laravel Container Builds the Mapping in One Place
$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(Concrete classes still need to be known somewhere. In ICanUp, that place is CommunicationServiceProvider.
The container builds a mapping of ThreadTypeEnum value -> resolver object. The domain service receives an already configured factory and does not own composition.
When another resolver is added, concrete wiring changes in one composition root.
MessageService Sees the Factory and Interface, Not the Concrete Resolver
public function create( CreateMessageData $data,): Message { return DB::transaction( function () use ($data): Message { $resolver = $this->threadResolverFactory ->make( $data ->threadContext ->type ); $thread = $resolver->resolve( $data->threadContextThis code shows the value of the factory most clearly.
MessageService knows that it needs a Thread. It gives the type to the factory, receives a resolver, and calls the shared resolve().
It does not need to know whether the Private resolver searches for an existing conversation, the Support resolver adds assignees, or Post Discussion works with a subject.
The Factory Hides Selection Without Erasing Resolver Differences
Resolver | Own Responsibility |
|---|---|
PrivateThreadResolver | Handles direct conversations and participants and can reuse an existing private thread. |
GroupThreadResolver | Creates group conversations, owner/member roles, and normalizes participants. |
SupportThreadResolver | Creates Support threads, supports guest context, and adds users with support reply permission as assignees. |
FeedbackThreadResolver | Creates Feedback threads and handles guest data plus feedback assignees. |
PostDiscussionThreadResolver | Requires a Post subject, creates a public discussion, and reuses the thread for the same subject. |
Post Discussion Shows Why the Resolver Deserves Its Own Object
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'This is no longer simply "create a Thread with a type". The resolver validates the subject, defines discussion identity through type + subject_type + subject_id, uses firstOrCreate(), and applies its own visibility.
If this behavior lived inside a generic factory, the factory would quickly become another large service containing branches for every Thread type.
The factory therefore selects a resolver while domain behavior stays inside that resolver.
ThreadContextData Lets the Factory Stay Small
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 = [], ) {}}The Factory does not receive ten parameters or decide which ones a concrete resolver needs.
It uses only type. The complete context is passed to the selected resolver, which reads the data relevant to its flow.
This is useful when different thread flows combine guest fields, subject, source context, and participant IDs in different ways.
How Factory Differs From Strategy in the Previous Article
Strategy | Factory |
|---|---|
The main abstraction is interchangeable algorithm behavior. | The main abstraction is selecting the object returned to the caller. |
SearchProvider itself is strategy behavior. | ThreadResolverFactory returns a resolver. |
The context works through one behavior contract. | The caller asks the factory to select an implementation by type. |
Focuses on executing an algorithm. | Focuses on the selection or construction boundary. |
What Changes When a New Thread Type Appears
- Add a value to
ThreadTypeEnum. - Create a resolver implementing
ThreadResolverInterface. - Keep its Thread lookup or creation rules inside that resolver.
- Add the type -> resolver mapping in
CommunicationServiceProvider. - Add tests for the new resolver and its domain invariants.
MessageService::create() should not receive another case containing domain logic.
Resolvers Can Be Tested Separately From MessageService
$resolver = app( PostDiscussionThreadResolver::class); $firstThread = $resolver->resolve( new ThreadContextData( type: ThreadTypeEnum::POST_DISCUSSION, subject: $post, createdById: $firstUser->id, ));The factory boundary also makes tests easier. Thread invariants do not all need to be verified through Message creation.
The Private resolver can test reuse and participants directly. Group tests roles and duplicates. Support and Feedback test guest data and assignees. Post Discussion tests subject requirements, availability, and reuse.
The integration point in MessageService remains very small.
When a Factory Would Be Overkill
If the system had only one Thread type, ThreadResolverFactory would provide little practical value. MessageService could depend directly on one resolver.
The Factory became useful when the caller remained the same and the contract remained the same, but multiple concrete creation flows appeared.
It is also important not to move domain logic into the factory. If make() starts creating participants, checking Post visibility, or processing guest data, the boundary becomes blurred again.
A good Factory decides who should do the work. It should not take that work away from the selected object.
Conclusion
In ICanUp, ThreadResolverFactory is small, and that is exactly why it is useful.
It isolates the mapping between ThreadTypeEnum and a concrete resolver. MessageService does not know every conversation type. Each resolver owns the rules for its own Thread flow.
Private, Group, Support, Feedback, and Post Discussion can evolve independently while the integration point remains the same: make(type), then resolve(context).
This is a useful Factory Pattern because it does not try to do too much. It removes selection logic from the caller and leaves domain behavior where it belongs.



