Best Practices

You are looking at the depricated version of the docs. See https://docs.lucidarch.dev for the latest documentation.

Running Jobs & Operations

Feature::run is a built in function provided by the abstract class Lucid\Foundation\Feature . The run function does all the magic of calling the underlying Job or Operation that is needed to be executed. However, there are several styles of calling them as showcased briefly in the body of our feature, explained below:

Reduced

$this->run(new ValidateArticleInputJob($request->input()));

In this approach we've already initialised our job and passed the parameter as required. This is only recommended when the job's signature is consisted of only one parameter, and it is obvious to the reader of the code what it is. As in our case here, this validation job requires the request's input.

The other approach (described in more detail below) is to explicitly specify the parameter's name in the second argument of run($component, $params) while the component has not been initialised and its class name is passed instead of the instance.

$this->run(ValidateArticleInputJob::class, [
  'input' => $request->input(),
]);

Since Lucid aims to enhance coding efficiency, in cases as such it is obvious what is intended here and would only cost a bit more time to write. If you are comfortable with the explicit approach, please use it. Otherwise, be extremely careful of when you choose to go with the reduced format.

Explicit

$article = $this->run(SaveArticleOperation::class, [
		'title' => $request->input('title'),
		'body'  => $request->input('body'),
	]
);

In this approach we use the Operation's fully qualified name of the class using ::class keyword and use it as the first parameter when running it. The second parameter is an array of parameters mapping the signature of our Operation class, so it would look like this:

__construct(string $title, string $content, string $slug)

Applies exactly the same for Jobs.

Sure enough, switching positions of the parameters will not have any effect and this is a major benefit for it allows us to keep things clean and tidy as we grow our codebase, and gives the freedom of choice within each context; within a Feature I can call the Job or the Operation whichever way fits my context. Similarly in an Operation when calling a Job. As for the called component (Job or Operation) it has the freedom to change its parameters whichever way fits the signature.

Nevertheless, the parameter names must match in both places.

This is the recommended approach, to always be explicit about what you are passing to make it as meaningful as possible to the reader of the code - which may as well be the future you, so do yourself a favour and be explicit.

Precise Signatures

To exemplify the above, let's consider the job SaveArticleJob from our previous example.

As you can see we've chosen to require each part of the article as a separate parameter instead of receiving the input as-is or an array of the fields (which might seem more intuitive at first, but it isn't). This pattern is what makes jobs isolated, self-contained and precise. When you read the Job class below you would need what is expected to be received without having to dig much further into the Job's class.

// Good
__construct(string $title, string $content, string $slug)

However when reading an undefined set of input or any sort of undefined structure (such as associative arrays and the like) it will require the reader to go through the implementation and deduce the fields, shown in the example below:

// Bad
__construct(array $articleFields)

As a rule of thumb: When it comes to signatures of components, always be as specific and as granular as you can be.

Also, it is rare for a Job to provide default values, even though useful when needed but should be reconsidered because if they keep increasing, too many optional parameters creates a bubble of "hidden" configurations. Preferable to be explicit at all times.

The Inevitable Exception

This approach is not recommended! Only when it is unavoidable, this approach is to be treated as an alternative to the above.

Of course, there is an exception to this rule, and if you face the rare case where you must provide a single element into the signature instead of each element, avoid using an associative array at all costs!

  • Their structures are never defined so one wouldn't know what to expect. Even if you are the super PHPDoc maintainer, you might miss one or two times.

  • They do not provide extended functionality in contrast with classes, where you can have methods and traits to enrich their functionalities.

  • Very badly handled in memory in contrast with objects.

To best deal with this situation is to create a class with the properties that are required by the job:

// Try to avoid this
__construct(ArticleInput $input)

Reusability

Continuing with our example SaveArticleJob is used to do both instead of having two jobs CreateArticleJob and UpdateArticleJob. This is common practice with Lucid unless there is necessity to have them separated; an indicator of needing separation is an over-complicated job or multiple usage logic based on a certain parameter. See the below snippet for an indicator:

// Bad

class SaveArticleJob extends Job
{
    private $title;
    private $content;
    private $slug;
    private $editor;
    private $isUpdating;
    private $id;

    public function __construct(
        string $title,
        string $content,
        string $slug,
        User $editor,
        int $id = null,
        bool $isUpdating = false,
    ){
        $this->id = $id;
        $this->slug = $slug;
        $this->title = $title;
        $this->editor = $editor;
        $this->content = $content;
        $this->isUpdating = $isUpdating;
    }

    public function handle() : Article
    {
        $article = null;

        if ($this->isUpdating && isset($this->id)) {

            $article = Article::find($this->id)->fill([
                'title'   => $this->title,
                'content' => $this->content,
                'slug'    => $this->slug,
            ]);

            $article->editor()->save($this->editor);
        } else {

            $article = Article::create([
                'title'   => $this->title,
                'content' => $this->content,
                'slug'    => $this->slug,
            ]);

            $article->author()->save($this->editor);
        }

        return $article;
    }
}

A few problems with this code:

  • Shared Responsibility: Since we introduced the flag isUpdating the responsibility of this task has now been shared with the calling component (Feature or Operation) and this is bad because the job should be the sole owner of the task, just like in a team, when I am performing a task I am the one assigned to it and responsible for its accomplishment.

  • Defying Single Responsibility: The responsibility of this job is no longer to save an article, it is now to create and update an article so it currently holds two responsibilities because the desired action has to be specified explicitly.

  • Complexity with Scale: Over time these actions will increase in complexity (create and update) which will cause this job to hide the complexity instead of dealing with it. It is as if we're putting the dust beneath the rug instead of throwing it where it belongs. Keeping in mind that this is a rather simple job that barely represents reality.

To solve these problems it is best to split these actions to two separate jobs. Even though it might seem like an overhead and you may feel like you're over-engineering, but it is balance, again. There is always a tradeoff between increased complexity vs. increased maintainability, and it is up to you and your team to decide. As a good practice we tend to avoid these confusing situations and come up with a certain decision early on and stick with it for the entire project, most often we decide to keep Job work to a minimum so we split in situations similar to this. And if it is desirable to share the article's saving functionality because it might involve more than this, then we go with an Operation as described next.

Operations

Based on our example, one of the steps to create an article is to generate the slug which is a shortened and reformatted version of the title. We need to generate the slug when creating an article as well as when updating it. For that reason we should share that logic as part of saving the article using an operation:

First of all, one idea might come to mind is "why not generate the slug when saving the article? since it is then when we really need to generate it", which is true, but then we will be doing two things in one job and that defies the presence of a single-responsibility job.

Assuming that we've separated creating and updating an article into two separate jobs CreateArticleJob and UpdateArticleJob, the code will be as followers

Before Operations

Creating

class CreateArticleFeature extends Feature
{
    public function handle(Request $request)
    {
        $this->run(ValidateArticleInputJob::class, [
            'input' => $request->input(),
        ]);

        $slug = $this->run(GenerateSlugJob::class, [
            'title' => $request->input('title'),
        ]);

        $article = $this->run(CreateArticleJob::class, [
            'title'   => $request->input('title'),
            'content' => $request->input('content'),
            'slug'    => $slug,
        ]);

        return $this->run(new RespondWithJsonJob($article));
    }
}

Updating

class CreateArticleFeature extends Feature
{
    /**
     * Article ID.
     * 
     * @var int
     */
    private $id;

    public function __construct(int $id)
    {
        $this->id = $id;
    }

    public function handle(Request $request)
    {
        $this->run(ValidateArticleInputJob::class, [
            'input' => $request->input(),
        ]);

        $slug = $this->run(GenerateSlugJob::class, [
            'title' => $request->input('title'),
        ]);

        $article = $this->run(SaveArticleJob::class, [
            'slug'    => $slug,
            'title'   => $request->input('title'),
            'content' => $request->input('content'),
        ]);

        return $this->run(new RespondWithJsonJob($article));
    }
}

After Operations

With Operations we can group relevant jobs together and unify the action of creating/updating an article since this is in most cases a similar action with similar parameters.

Creating

class CreateArticleFeature extends Feature
{
    public function handle(Request $request)
    {
        $this->run(ValidateArticleInputJob::class, [
            'input' => $request->input(),
        ]);

        $article = $this->run(SaveArticleOperation::class, [
            'title'   => $request->input('title'),
            'content' => $request->input('content'),
        ]);

				return $this->run(new RespondWithJsonJob($article));
    }
}

Updating

class UpdateArticleFeature extends Feature
{
		/**
     * Article ID.
     * 
     * @var int
     */
    private $id;

    public function __construct(int $id)
    {
        $this->id = $id;
    }

    public function handle(Request $request)
    {
        $this->run(ValidateArticleInputJob::class, [
            'input' => $request->input(),
        ]);

        $article = $this->run(SaveArticleOperation::class, [
						'id'      => $this->id,
            'title'   => $request->input('title'),
            'content' => $request->input('content'),
        ]);

				return $this->run(new RespondWithJsonJob($article));
    }
}

Now both features seem to be redundant and have no value in maintaining two classes that do almost the same thing. This is true, but! It is rare for two features to be that similar, here we will portray the difference between the Features and how each has its own scope and steps to perform:

  • When creating an article, the subscribers to this author might like to be notified so we send them notifications of the newly published article.

  • When updating, there is no need to send notifications, but the validation we perform is different, not only we validate that the input is correct but we also must validate that the author has edit access to the article.

Creating

class CreateArticleFeature extends Feature
{
    public function handle(Request $request)
    {
        $this->run(ValidateArticleInputJob::class, [
            'input' => $request->input(),
        ]);

        $article = $this->run(SaveArticleOperation::class, [
            'title'   => $request->input('title'),
            'content' => $request->input('content'),
        ]);


        $notificationStatus = $this->run(NotifySubscribersOperation::class, [
            'authorId' => Auth::user()->getKey(),
        ]);

				$this->run(BroadcastNotificationStatusToUserJob::class, [
					'status' => $notificationStatus,
				]);

        return $this->run(new RespondWithJsonJob($article));
    }
}

Updating

class CreateArticleFeature extends Feature
{
    /**
     * Article ID.
     *
     * @var int
     */
    private $id;

    public function __construct(int $id)
    {
        $this->id = $id;
    }

    public function handle(Request $request)
    {
        $this->run(ValidateArticleInputJob::class, [
            'input' => $request->input(),
        ]);

        $this->run(AuthoriseArticleUpdateJob::class, [
            'user'      => Auth::user(),
            'articleId' => $this->id,
        ]);

        $article = $this->run(SaveArticleOperation::class, [
            'id'      => $this->id,
            'title'   => $request->input('title'),
            'content' => $request->input('content'),
        ]);

        return $this->run(new RespondWithJsonJob($article));
    }
}

This keeps each feature's scope clear in addition to the benefit of refraining from having plenty of conditionals checking on $isUpdating flags everywhere in the code which makes it almost impossible to follow in a logical sequence, especially after each of these features grow in requirements.

With the operation, we have scoped the conditional to storage since it is the most similar in our task, and in case you see that growing to also be too large to maintain, feel free to split it into two operations: one for creating and one for updating.

Favour multiple scoped classes for they are cheap in code but priceless in clarity. In contrast with single classes bloated with multitudes of functionalities and responsibilities.

Note: It might have occurred to you that we could've passed $request into the operation as well since it is doing more work than a job can. This is also to be avoided, for the simple reason of maintaining precise signatures beyond the scope of the feature.

Last updated