« back published by @mmartin_joo on March 22, 2022
Domain-Driven Design with Laravel: Actions in Action
What Is Domain-Driven Design or DDD?
Domain-Driven Design is a software development approach that tries to bring the business language and the source code as close as possible.
I think that's the most simple yet correct definition of DDD. To be a little bit more specific it achieves this by making every important "stuff" a first-class citizen. If "Change invoice status" or "Increase order item quantity" are two sentences that your business people say a lot then you probably should have
- ChangeInvoiceStatus
- IncreaseOrderItemQuantity
classes somewhere in your application. That's one example of being a first-class citizen.
Now let's say you're working on some server management / monitoring application. Maybe a commercial one, or some internal tool for your own projects. What is the single most important "stuff" in this application? In my opinion, it's the server status. If it says "Healthy" everyone is happy, but if it says "Down" you know you're about to have a hard day. We know it's important but still, it's just a string in our database table. It's the 15th attribute on our Server model. And we're changing it from "Healthy" to "Deploying" in the 873rd line of some random Command or Job, or in a Model somewhere, or even worse in a Controller. Instead of having these string values and changing them, we can have classes like:
- ServerStatus/Healthy
- ServerStatus/Down
- ServerStatus/Deploying
We can also have transitions like:
- HealthyToDeploying
So this is what I mean by making things, first-class citizens. This is what Domain-Driven Design is all about (and a little bit more, but now this is the important thing).
Monster Controllers
When you create a new Laravel project all you have by default is controllers and models. So it's very natural to put code in these two classes. You write your database queries in the models and the "glue" code in your controllers. But the problem is, basically almost anything can labeled as "glue" code, so with time your controllers become huge, unmaintainable, monster classes. Also you overload your models with those queries.
Let's write a simple API endpoint to illustrate my point. The API will be POST /posts, so it's a blog application. Something like Medium where a lot of authors can publish, and readers can follow them. Here are some things we need to do, when someone publishes a new post:
- Moderate the content. If it contains some bad word we abort the request.
- Generate the keywords. We count all the unique words in the content and get the 5 most important keywords.
- Save the post.
- Send notification. Send an e-mail notification to each follower.
- Also you can send a publishAt date in the POST request. If it has a value it means it's a scheduled post in the feature. If it's scheduled we won't notify the followers, just send a notification to the author.
- Return the new post.
That's our spec. First, let's write by only using the controller and the model. Let's write some junior code!
Moderate the Content
public function store(StorePostRequest $request)
{
/** @var User $author */
$author = $request->user();
$title = $request->getTitle();
$body = $request->getBody();
$content = Str::of($title)->append($body);
if($content->contains(['bad'])) {
abort(Response::HTTP_UNPROCESSABLE_ENTITY, 'Your post contains bad words...');
}
}
In the first part, we append the title and the body of the post and we check if it contains the word 'bad'. This is our content moderation AI for this toy project. If it's bad, we abort the request with 422.
Generate the Keywords
$wordCounts = [];
foreach (Str::of($content)->explode(' ') as $word) {
if (!isset($wordCounts[$word])) {
$wordCounts[$word] = 0;
}
$wordCounts[$word]++;
}
$wordCounts = collect($wordCounts)->sortDesc()->take(5);
The next is to get the most used keyword from the content. It's also a naive algorithm, we just explode the string by spaces, and increase a counter at every occurrence. After that we sort this array by count and take the 5 most used words.
Save the Post
$post = Post::create([
'title' => $title,
'body' => $body,
'publish_at' => $request->getPublishAt(),
'author_id' => $author->id,
'keywords' => $wordCounts->keys(),
]);
That's easy, we just create a new Post model.
Notify the Followers
if ($post->is_published) {
$author->followers->each(fn(User $follower) => $follower->notify(new PostPublishedNotification($post)));
} else{
$author->notify(new PostScheduledNotification($post));
}
If the post is published based on the publish_at column (is_published is an accessor based on that date) we send each follower a PostPublishedNotification. If it's not published we send the author a PostScheduledNotification.
Return the New Post
return response(new PostResource($post), Response::HTTP_CREATED);
Okay, I didn't really need to make a new heading for this one...
These are our steps. Now let's see everything together.
Put Everything Together
public function store(StorePostRequest $request)
{
/** @var User $author */
$author = $request->user();
$title = $request->getTitle();
$body = $request->getBody();
$content = Str::of($title)->append($body);
if ($content->contains(['bad'])) {
abort(Response::HTTP_UNPROCESSABLE_ENTITY, 'Your post contains bad words...');
}
$wordCounts = [];
foreach (Str::of($content)->explode(' ') as $word) {
if (!isset($wordCounts[$word])) {
$wordCounts[$word] = 0;
}
$wordCounts[$word]++;
}
$wordCounts = collect($wordCounts)->sortDesc()->take(5);
$post = Post::create([
'title' => $title,
'body' => $body,
'publish_at' => $request->getPublishAt(),
'author_id' => $author->id,
'keywords' => $wordCounts->keys(),
]);
if ($post->is_published) {
$author->followers->each(
fn (User $follower) => $follower->notify(new PostPublishedNotification($post)));
} else {
$author->notify(new PostScheduledNotification($post));
}
return response(new PostResource($post), Response::HTTP_CREATED);
}
Okay... I consider a controller like this, well ugly. It's not the end of the world right now, but it's only gonna get worse. And it will happen faster than you think!
So that's our first stage, the monster controllers:
- Most DB related stuff goes into the model. But as you can see typically controllers have a lot of create() and update() calls. And also this is the place where relationships are made.
- Everything else goes into the controller.
Services (and Repositories) for the Rescue
We want to spread out this code to multiple classes because they have different responsibilities. Where do we start? Two good solutions can be Service and Repository classes. These concepts come from Domain-Driven Design or DDD.
What Is a repository Class?
I have a dedicated in-depth article about repository classes here.
It's a class that contains database queries. That's it. In the most simple case it has no abstraction no special methods, just queries. We have only one query in this controller (but in the real world we'll have a lot more), so let's extract it out to a repository class:
class PostRepository
{
public function create(
string $title,
string $body,
User $author,
Collection $keywords,
?Carbon $publishAt,
): Post {
return Post::create([
'title' => $title,
'body' => $body,
'publish_at' => $publishAt,
'author_id' => $author->id,
'keywords' => $keywords->all(),
]);
}
}
Instead of separate arguments, you can use DTOs, but in this article, I want to talk about Actions exclusively.
As you can see it's very simple. We just extracted out the query and put it in a create method. It's a really simple example, but the main benefit is clear: it's reusable. Now we can write an ImportPosts job, that calls the PostRepository::create() function. When this code was in the controller it was impossible to reuse it. If you wish you can simplify this code by using a DTO instead individual arguments.
Now let's do something with the other code in the controller. But where do we put it?
What Is a Service Class?
Service is kind of a broad concept. I give you some examples:
- You have a finance app, and you need something that calculates metrics. It can be a MetricsService class. It has methods like getProfitMargin().
- You need to communicate with Twitter API in your application, so you maybe have a TwitterService. It has methods like getTweets().
- You have a complicated class that imports products from a CSV? You can introduce a ProductImportService class for that.
I think you get the main idea. Now, we can create a PostService class:
class PostService
{
public function __construct(private readonly PostRepository $posts)
{
}
/**
* @throws ContentModerationException
*/
public function create(string $title, string $body, User $author, ?Carbon $publishAt): Post
{
$content = Str::of($title)->append($body);
$this->moderateContent($content);
$keywords = $this->getKeywords($content);
$post = $this->posts->create($title, $body, $author, $keywords, $publishAt);
$this->sendNotifications($post);
return $post;
}
/**
* @throws ContentModerationException
*/
private function moderateContent(string $content): void
{
if (Str::of($content)->contains(['bad'])) {
throw new ContentModerationException('Your post contains bad words...');
}
}
private function getKeywords(string $content): Collection
{
$wordCounts = [];
foreach (Str::of($content)->explode(' ') as $word) {
if (!isset($wordCounts[$word])) {
$wordCounts[$word] = 0;
}
$wordCounts[$word]++;
}
return collect($wordCounts)
->sortDesc()
->take(5)
->keys();
}
private function sendNotifications(Post $post): void
{
if (!$post->is_published) {
$post->author->notify(newPostScheduledNotification($post));
return;
}
$post->author
->followers
->each(fn(User $follower) => $follower->notify(newPostPublishedNotification($post)));
}
}
We have one public method which is create() and we have the:
- moderateContent()
- getKeywords()
- sendNotifications()
Nice and clean. Now we have a class dedicated to database queries and another one dedicated to other post related stuff. But we have a problem now. The PostService class has too many responsibilities. It knows about keywords, notifications, moderation, post creation. And in this example we have only one API endpoint, only one operation! Just imagine what will happen to this class if 'it were a real project.
It becomes the monster controller. But now we call it PostService instead of PostController.
Another downside of this approach that now we have two classes that contains post related operations. Like the create() method. It exists on the service and on the repository as well. So, if you're a developer on this project how would you know that you have to call the PostService::create() instead of the PostRepository::create() if you want to create a post? What will guarantee that the getKeywords() method won't be duplicated? Let's say I'm a new developer on this project. I see the PostRepository. I also see that it needs a keywords array, so I construct this array somehow and pass it to the PostRepository::create() function. Now I have duplicated code and a new keyword algorithm that possibly works differently than the original.
What can we do to make life better?
Meet the Action Class
Basically an action class is a lightweight service that has only one responsibility. Let's see an example:
class ModerateContent
{
/**
* @throws ContentModerationException
*/
public function execute(string $content): void
{
if (Str::of($content)->contains(['bad'])) {
throw new ContentModerationException('Your post contains bad words...');
}
}
}
This is an action class dedicated to moderating the content. The most important thing: an action class has a very specific name. That is like the user story. And an action class has only one method. I name this method execute. Basically, every action that triggered by the user gets a dedicated class. And we also have some other non user triggered actions, like the one above. If you take a look at the actions folder you get a very good idea what the application does:
Our application can:
- Create a post
- Moderate the content
- Send notifications about a post
These classes are like Jira issues. Here's the SendPostNotification:
class SendPostNotifications
{
public function execute(Post $post): void
{
if (!$post->is_published) {
$post->author->notify(new PostScheduledNotification($post));
return;
}
$post->author
->followers
->each(fn(User $follower) => $follower->notify(new PostPublishedNotification($post)));
}
}
It's simple, only has one responsibility, it's reusable and maintainable. Our last and most complicated action is the CreatePost:
class CreatePost
{
public function __construct(
private readonly ModerateContent $moderateContent,
private readonly SendPostNotifications $sendPostNotifications,
private readonly PostService $postService
) {}
/**
* @throws ContentModerationException
*/
public function execute(string $title, string $body, User $author, ?Carbon $publishAt): Post
{
$content = Str::of($title)->append($body);
$this->moderateContent->execute($content);
$keywords = $this->postService->getKeywords($content);
$post = Post::create([
'title' => $title,
'body' => $body,
'publish_at' => $publishAt,
'author_id' => $author->id,
'keywords' => $keywords->all(),
]);
$this->sendPostNotifications->execute($post);
return $post;
}
}
As you can see in the constructor actions classes are easily composable. And we still have the PostService class that contains a getKeywords() method. It's not an action, it's not a database query so I kept it in the PostService. It's perfectly okay to keep a service class some general operations (mainly getters).
In this example I removed the PostRepository class because it has no real added value. In this way it's very clear: if you want to create a new post, you have call the CreatePost action. It's visible, clear, and understandable.
Comments
As a bonus let's add POST /comments API. We can create an AddComment action to make it happen. I name it "Add" because "I add a comment to a post", so the comment doesn't live by its own, in my opinion it's not something that "I create out of thin air".
Here's the AddComment action:
class AddComment
{
public function __construct(private readonly ModerateContent $moderateContent)
{
}
public functionexecute(Post $post, User $user, string $body): Comment
{
$this->moderateContent->execute($body);
return $post->comments()->create([
'user_id' => $user->id,
'body' => $body
]);
}
}
You can see in this action we reuse the ModerateContent action. This is another great attribute of actions: they are easily reusable. This is also true to services, but I think it's just a much cleaner way.