In one of my current projects, I needed to be able to broadcast email announcements to all of the users in the system.
Here's what my AnnouncementsController
looks like:
class AnnouncementsController extends Controller
{
// ...
public function store()
{
$this->validate(request(), [
'subject' => 'required',
'message' => 'required',
]);
Announcement::create(request(['subject', 'message']))->broadcast();
return redirect()->route('admin.announcements.create');
}
}
If you've read about enough patterns and principles, there's a decent chance you saw this line:
Announcement::create(request(['subject', 'message']))->broadcast();
...and immediately thought to yourself:
"What?! An announcement shouldn't be able to broadcast itself!"
I used to think that too, but over the last few years I've started to think differently.
The Dreaded Do-er Class
An Announcement
is just some words shouted into a crowd or printed in the newspaper. Words can't print themselves, that doesn't make any sense!
No, to broadcast an announcement you need something that actually can do the broadcasting, like an Announcer
, Broadcaster
, or maybe even an AnnouncementBroadcaster
:
class AnnouncementBroadcaster
{
private $queue;
public function __construct($queue)
{
$this->queue = $queue;
}
public function broadcastAnnouncement($announcement)
{
$this->queue->dispatch(new BroadcastAnnouncement($announcement));
}
}
Does this sort of class look familiar? You may have also seen this class by its other known alias, the AnnouncementService
.
What's a Method Anyways?
The fundamental misunderstanding here is thinking that methods are things an object can do.
If you believe that the methods on an object represent the abilities of that object, then of course an Announcement
having a broadcast()
method sounds silly.
But what if methods weren't the things an object could do? What if they were the things you could do with that object?
If methods were the actions an object afforded us, then it would make perfect sense to be able to broadcast()
an Announcement
, wouldn't it?
Double Standards
Ever used the format
method on the DateTime
class before?
$date = new DateTime('2017-01-23');
$date->format('l F j, Y');
// Monday January 23, 2017
I bet you've never thought to yourself:
"Hey, a DateTime shouldn't be able to format itself!"
...because of course it's not "formatting itself."
By having a format()
method, the DateTime
class is telling us:
"Hey, I can be formatted!"
Just like a window can be opened, a car can be refueled, or an announcement can be broadcast.
The DateTimeFormatter Nightmare
If you take our AnnouncementBroadcaster
from before and try to apply the same line of thinking to formatting dates, you might end up with something like this:
class DateTimeFormatter
{
private $formattingRules;
public function __construct($formattingRules)
{
$this->formattingRules = $formattingRules;
}
public function formatDateTime($dateTime, $formatString)
{
// ...
}
}
Imagine the consequences of this if you ever needed to format a date in a template?
Say we wanted to display a blog post with a formatted published_at
date.
Since DateTimeFormatter
has its own dependencies, we'd need to inject a pre-built instance into our controller:
class PostsController
{
private $dateTimeFormatter;
public function __construct(DateTimeFormatter $dateTimeFormatter)
{
$this->dateTimeFormatter = $dateTimeFormatter;
}
// ...
}
Then we'd need to pass that through to our template to format our published_at
field:
class PostsController
{
// ...
public function show($postId)
{
$post = Post::findOrFail($postId);
return view('posts.show', [
'post' => $post,
'dateTimeFormatter' => $dateTimeFormatter,
]);
}
}
Finally we could use the DateTimeFormatter
to format the published_at
field in our template:
<article>
<h1>{{ $post->title }}</h1>
<p><small>{{ $dateTimeFormatter->formatDate($post->published_at, 'l F j, Y') }}</small></p>
</article>
All that because "a date shouldn't format itself," right?
"But That's Different!"
DateTime
objects are just simple self-contained value objects, right?
Being able to do $announcement->broadcast()
is much more "dangerous" than $date->format('Y-m-d')
, because it clearly depends on some globally available queue service or mail service.
Well, it turns out the DateTime
class has global dependencies too!
Each DateTime
object needs to know about the timezone database to know which timezones are available, and how to convert between them.
What if I wanted one DateTime
instance to use one timezone database, and another instance to use a different timezone database?
I can't really do that, but does anyone really worry about it? Of course not!
It's not a practical enough concern to warrant completely throwing away the convenience of being able to convert between timezones without a complex dependency graph.
Combating Paranoid Design
So why do we apply that level of paranoia to something like $announcement->broadcast()
?
Yes, Announcement
has an implicit dependency on whatever queue service has been configured for the environment:
class Announcement extends Model
{
protected $guarded = [];
public function broadcast()
{
dispatch(new BroadcastAnnouncement($this));
}
}
But why is our default reaction to automatically think this is "wrong"?
Why do we immediately jump to an AnnouncementBroadcaster
even if this implicit dependency has no practical consequences in our application?
Just Enough Flexibility
In this example, the job dispatcher is resolved out of Laravel's Service Container, so it's trivial to use a different implementation per environment, or when running our tests:
$container->bind(Illuminate\Contracts\Bus\Dispatcher::class, function () {
return new SomeOtherJobDispatcher;
});
This is already a lot more flexibility than we have with the timezone database.
It's not until configuring the dispatcher at the application level becomes insufficient that this design actually introduces any challenges.
Even then, it's simpler and more flexible to accept a dispatcher as a parameter than it is to create an AnnouncementBroadcaster
:
class Announcement extends Model
{
protected $guarded = [];
public function broadcast($dispatcher)
{
$dispatcher->dispatch(new BroadcastAnnouncement($this));
}
}
This lets you preserve the mental model that methods are things you can do with an object, and still lets you use a different dispatcher every time you call broadcast
.
Co-Locate Data and Behavior by Default
I'll admit, you can't always attach the behaviors that operate on an object to that object itself.
But more and more, I'm convinced it's worth striving for before turning what should be a method into an entirely new class.
Create an AnnouncementBroadcaster
as a last resort, not by default.