Components

Here we define the core components of Lucid: Feature, Job and Operation.

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

Out of the SOLID principles - of which we are disciples - we've taken Single Responsibility seriously and created components that define the responsibilities beyond an MVC's controller or any other modern application's entry point, be it a route/request or a command. Which is where it is loose and chaos builds its nest. Within a controller we can do anything, and whichever architecture we follow (or don't) we can still create a mess due to not having a specific guideline that helps contain the chaos.

Here we define the responsibility of each component starting from MVC and moving through Lucid to keep codebase organised, defined and understandable at a glance, supporting whichever design patterns we decide to adopt:

Router & HTTP Kernel

A router is like a door that one can open to enter a room. A door does not concern itself with what the room contains or what its purpose is. It is simply a door. However, sometimes we have some security pass for the door to open, so a router can be configured to include components that perform security checks and other forms of entrance preparation.

Responsibility: Expose a feature from the application over HTTP, routing the request to the corresponding controller method.

What you can do in this space:

  • Define the URL you want your application allow entries through.

  • Define the controller method that should handle the request.

  • Perform pre-flight\middleware work such as request authorisation, request preparation [does not include input validation]. The work that happens in the Http Kernel (before executing features) can also take advantage of the Lucid workflow, such as running a Job or an Operation from the Middleware.

Controller

In an effort to minimise the work of controllers, for they are not here to do work for the application but to point the request in the right direction. In Lucid terms, to serve the intended feature to the user. Eventually we will end up having one line within each controller method, achieving the thinnest form possible.

Responsibility: Serve the designated feature.

What you should do in a controller:

  • Serve a feature.

  • Prepare input as required by the feature that's being served [does not include input validation]

Feature

Represents a feature that the application provides. It runs Jobs and Operations [a.k.a. components] to perform its tasks. They are thought of as the steps in the process of serving its purpose. A Feature can be served from anywhere, most commonly Controllers and Commands. Can also be queued to run asynchronously, such as Jobs and Operations.

Responsibility: Perform the steps required to accomplish the feature by running Jobs and Operations.

What you should NOT do in a feature:

  • Complex conditional logic: The feature passes output from Jobs and Operations but it barely knows anything about what goes on inside them. It only knows the sequence in which they should run and [maybe] some basic logic that is better be avoided and delegated to jobs and operations of possible.

  • Parse or transform output from components: the output from one should always go as-is to the next. This guarantees consistency and predictability when reading the feature's code. If parsing or transformation is needed use a Job or an Operation to do that.

  • Call another Feature.

class UserController extends Controller
{
	public function login()
	{
		return $this->serve(LoginUserFeature::class);
	}
}

Inside Features

Think of a Feature as your task list when you want to implement it in the application, looking at the class's handle method should provide an overview of the steps required to serve the feature to the user (or any consuming party) without having to know too much details about the inner workings of each step. Also looking at the signature of the Feature should give the idea of what is required for it to be served.

class CreateArticleFeature extends Feature
{
	public function handle(Request $request)
	{
		$this->run(new ValidateArticleInputJob($request->input()));

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

		$slug = $this->run(new GenerateSlugJob($request->input('title')));

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

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

As shown in the example above, there are several ways to run Jobs in features that are explained in details in the definition of Jobs .

Let's examine the code above in depth...

The handle method

public function handle(Request $request)

This method is called automatically when a running $this->serve(Feature::class) and it goes through Laravel's IoC to resolve dependencies. In this example we included the Request class to be resolved so that we can access it and pass input to Jobs. Request could've been any other class in the application that can be resolved using IoC. Example:

public function handle(MyCustomClass $mcc)

This is the recommended way of using classes to maintain testability by interchanging class instances with their mocks.

The handle method is common to all Lucid components: Feature, Job and Operation and it behaves the same everywhere.

Job

Jobs do the actual work. Being the smallest unit in the Lucid architecture, a Job should do one thing, and one thing only. That is: perform a single task.

Our objective with Jobs is to limit the scope of a functionality so that we know where to look for an implementation, and when we're within its context we don't tangle responsibility with jobs or any other components in the codebase.

Things to be careful of with Jobs:

  • Never call another Job from within a Job, it defeats the whole purpose of having them and will create obscure nested logic that will be hard to follow and maintain.

  • Jobs do not know about other [surrounding] jobs, they operate in isolation and are unaware of their surroundings. They are selfish, only concerned with themselves and their needs to operate.

  • The input parameters (constructor parameters) of a Job - a.k.a. Job signature - should be about the Job itself, not concerned with where it will be called from and in which case. Here's a personification of a Job speaking:

    I, as a Job, in order to fulfil my task, I need "this" and "that" from you, and once I am done I will return "this" to you.

  • To validate your choice with jobs, simply ask yourself: "what does this job do?" and the answer should be "It [does this] then returns [that]" where:

    • [does this]: should not include an "and" and should be made up of few words

    • [that]: should either be a defined structure such as an object, or a status response. A tip here is a to avoid associative arrays as much as possible, or at all if possible, for they ramp up undefined structures and it will require more and more cognitive load to deduce meaning out of looking at the code.

It is a common practice to share jobs, make them as shareable as possible but be careful not to have complicated jobs just for the sake of reusability. It has to be balanced and it is up to you and your team to find the balance that fit you best.

Operation

This component came to Lucid at a later stage due to the need for grouping common Jobs that together make up one useful functionality, working as a team in a certain sequence to achieve a goal, yet that goal is still one step in the bigger goal (Feature). Technically, Operation classes are similar to Features and we would use them the same, meaning that they both have run($component,$params) method to run jobs, however conceptually they are completely different. Features can run Operations and Jobs. Operations can run Jobs only.

Their purpose is to increase the degree of code reusability by providing composite functionalities.

Here is an example based on our CreateArticleFeature code sample:

Upon creating an article, we would like to send notifications to the subscribers of the author. This functionality is made up of three steps:

  1. Retrieve subscribers: here we need to be careful for there could be a huge list of them, so we might need to paginate through them and send these notifications in batches

  2. Enqueue notifications jobs: we sure won't be issuing thousands of notifications at once. Assuming that we are using some external API to perform this action, they must have limitations, such as a maximum amount of "destinations" to send to - say 200?

  3. Return status: a summary of the number of notifications that were sent and whether it succeeded to enqueue their jobs. This does not mean that the notifications were actually sent, just that they were enqueued to do so, their sending status needs to be followed up depending on the queue (i.e. Laravel's Horizon)

Each of these steps, naturally will have a job to perform them, but they are tightly coupled in the sense that whenever we need to notify subscribers we will need to perform these steps together. If we were to not have an Operation that groups them we would have had to include the three jobs together every time, and this is what Operations helps eliminate.

Here's the snippet:

class NotifySubecribersOperation extends Operation
{
    private $authorId;

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

    public function handle()
    {
        $subscribers = $this->run(PaginateSubscribersJob::class, [
            'authorId' => $this->authorId,
        ]);

        $enqueued = $this->run(EnqueueNotificationJob::class, [
            'authorId' => $authorId,
            'subscribers' => $subscribers,
        ]);

        return $this->run(DetermineEnqueueingNotificationJobsStatus::class, [
            'enqueued' => $enqueued,
        ]);
    }
}
  1. PaginateSubscribersJob queries the database based on the given authorId and returns an iterator instance [taking advantage of PHP's generators in such case is a great added value in this case].

  2. EnqueueNotificationJob loops over the iterator instance, determines the means of notifying the subscriber and enqueues the corresponding notification Job.

  3. DetermineEnqueueingNotificationJobsStatus collects the statuses and using some logic determines the status of this operation and returns it back to the calling Feature.

Jobs, Operations and Features in Lucid can all be enqueued just like any other Laravel job instance.

Now we just run that operation within our Feature as we usually do:

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

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

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

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

Last updated