jvv8's avatar
Level 2

I'm doing Lucid Architecture all wrong... advice?

I am using the Lucid framework and I'm struggling with a few concepts.

  1. Do I return views from the Job or the Feature or the Controller?
  2. Where is the request validated?
  3. Do I dissect the request into variables or pass the request along to the Feature/Job
  4. I have many joins on most of my front-end requests so in the past I've just used raw queries and hydrated the result into the model. I realize this isn't the best practice and I'd like to use Repositories in the Data folder, but I'm not sure how that would look in my case.

Here's a fictitious situation but I hope it illustrates the problem and someone can recommend the correct way to do this.

For this example, I wish to return a view for showing Articles.

namespace App\Services\Web\Http\Controllers;
use Illuminate\Http\Request;
use Lucid\Foundation\Http\Controller;
use App\Services\Web\Features\ListArticlesFeature;
class ArticlesController extends Controller {
    public function index(Request $request)
    {
        return $this->serve(ListArticlesFeature::class);
    }
}
namespace App\Services\Web\Features;
use Lucid\Foundation\Feature;
use Illuminate\Http\Request;
use App\Domains\Category\Jobs\GetArticlesJob;
use App\Domains\Session\Jobs\GetSessionLangJob;
class ListArticlesFeature extends Feature
{
    public function handle(ArticlesRequest $request)
    {
        $lang = $this->run(GetSessionLangJob;::class);
        return $this->run(GetArticlesJob::class, compact('request','lang'));
    }
}

Here's where things go bad quickly. With Eloquent I think it would take many more queries, but I'd love to be proven wrong here. Would also be nice to get the SQL into a repository or model? Using raw sql, I hit the database only 3 times, but it's not pretty. The system also needs to returning either a view or json (for when the user filters the results). To complicate things further, the database has different columns for various languages for Articles (title, title_en, title_fr, title_es etc). Again, I don't know if returning a view is the way to go here. I think there could be a nicer way to deal with pagination too.

namespace App\Domains\Articles\Jobs;

use Lucid\Foundation\Job;
use Framework\Category;
use Framework\Article;
use Carbon\Carbon;
use DB;

class GetArticlesJob extends Job
{
    private $request;
    private $lang;
    /**
     * Create a new job instance.
     *
     * @return void
     */
    public function __construct($request, $lang)
    {
       
        $this->request = $request;
        $this->lang = $lang;
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        // assume articles are shown only if less than 2 years old.
        $oldest_date = Carbon::now()->subYears(2);
        //order by views in the last week
        $view_date = Carbon::now()->subWeeks(1);
        $offset = $this->request->offset;
        $limit = $this->request->limit;
        $query = "SELECT SQL_CALC_FOUND_ROWS A.*, AI.image, SUM(PV.views) as total_views";
        if($this->lang == env('APP_LOCALE_LANG'){
            $query .= ", A.title as _display_name";
        }else{
            $query .= ", A.title_" . $this->lang . " as _display_name",
        }
        $query .= "
                    FROM article AS A
                    INNER JOIN article_view AS AV ON AV.article_id = A.id AND AV.date > :view_date
                    INNER JOIN article_image as AI ON AI.article_id = A.id AND AI.size = 'medium'
                    LEFT JOIN category as C ON C.id = A.root_category_id
                    WHERE A.date >= :oldest_date
                    GROUP BY A.id
                    ORDER BY `total_views` DESC
                    LIMIT :offset, :limit";
        $result = DB::select($query,compact('view_date','oldest_date','offset','limit'));
        $articles = Articles::hydrate($result);
        $count_result = collect(DB::select("SELECT FOUND_ROWS() as total_article_count"))->first();
        $total_article_count = $count_result->total_article_count;
        
        
        $next_page_url = "";
        if(count($articles) == $limit){
                // probably can try loading another page
                $next_page = $page + 1;
                $next_page_url = $request->fullUrlWithQuery(['page' => $next_page]);
        }else{
                $next_page_url = "";
        }
        if($this->request->ajax()){
            return [
                'articles' => view('articles.ajax.show')->with(compact('articles'))->render(),
                'next_page_url' => $next_page_url,
                'article_count' => count($articles),
                'total_article_count' => $total_article_count
            ];
        }
        $root_category_ids = $articles->pluck('root_category_id');
        $category_where_stmt = "";
        foreach($root_category_ids as $id){
            if($category_where_stmt != ""){
                $category_where_stmt .= " OR";
            }
            if($id > 0){
                $category_where_stmt .= " category_id = '" . $id . "'";
            }
        }
        $query = "SELECT * ";
        if($this->lang != env(APP_LOCALE_LANG) && $this->lang != NULL){
            $category_locale = '_' . $this->lang;
        }else{
            $category_locale = "";
        }
        $query .= ', category_name' . $category_locale . ' as _display_name';
        $query .= " FROM category WHERE " . $category_where_stmt . ' ORDER BY _display_name ASC';
        $result = DB::select($query);
        $root_categories = Category::hydrate($result);
        return view('articles.show',compact('articles','total_article_count','next_page_url','root_categories'));

    }
}
0 likes
5 replies
Mulkave's avatar

Hello John,

Glad to hear you've decided to jump on the Lucid arch's bandwagon. I hope the below answers your questions appropriately.

1- Do I return views from the Job or the Feature or the Controller?

The chain should always go as Controller -> Feature -> Job. Which is the expected structure by anyone reading a Lucid project, so if you were to return views or anything else from an intermediate step like Controller or Feature, it would break the chain and would end up unpredictable.

2- Where is the request validated?

This is up to you to decide. Preferably in Lucid, everything to go in a Job. So at the beginning of the feature you'd have a job to validate the incoming request:

$this->run(ValidateUserRegistrationJob::class, ['input' => $request->input()]);
  • Then that job would call a validator that it injects
handle(UserValidator $validator) {
    $validator->validatorForRegistration();
}
  • The UserValidator class would live within the User domain (or any domain relevant to what's being validated)

  • It would extend Lucid\Foundation\Validator; as follows:

use Lucid\Foundation\Validator;

class UserValidator extends Validator
{
    protected $rules = [
        'name' => 'required',
    ];
}
  • With that, simply calling $validator->validate($input)would do the validation and if it doesn't pass an exception is thrown which then will be handled by the Exception Handler of the app

3- Do I dissect the request into variables or pass the request along to the Feature/Job

  • The controller passes the request as is to the Feature (this happens automatically using injection at the Feature's handle method so no need to manually pass the request)
  • The Job specifies what it needs to work, which is specific variables/values from the request which are then passed by the Feature to the Job accordingly

4- I have many joins on most of my front-end requests so in the past I've just used raw queries and hydrated the result into the model. I realize this isn't the best practice and I'd like to use Repositories in the Data folder, but I'm not sure how that would look in my case.

I am assuming that this is a structure question based on "I'm not sure how that would look in my case".

One generic way is to have ../Data/Repositories/UserRepository.php hosting your user queries. If you were to be more specific and account for changes in storage later on, you'd specify what these repositories work with. i.e. .../Data/Repositories/MySQL/UserRepository.php and then use IoC to autoload accordingly.

Code examples feedback

First snippet

Perfectly correct

Second snippet

1-

$lang = $this->run(GetSessionLangJob;::class);

Good example of using jobs

2-

return $this->run(GetArticlesJob::class, compact('request','lang'));
  • A job should do one thing at a time, here it is doing at least two. GetArticlesJob should return a list of articles, i.e. a Collection. This ensures reusability, i.e. if you were to call the GetArticlesJob but would rather return the result as JSON instead of a view

  • GetArticlesJob should not receive the request but rather what it needs exactly from the request. This ensures code readability and predictability. When I read the Feature I need to understand all the requirements of each step, besides the sequence and the end result

  • The Feature should be explicit about what it returns. To accomplish that, you need to have a job to return a view from the given data and for that you can use the built-in RespondWithViewJob which is usually shipped with each Lucid installation at App\Domains\Http\Jobs\ RespondWithViewJob

  • GetArticlesJob is doing too much work itself, I suggest you take a look at (Operations)[https://tech.vinelab.com/introducing-operations-the-lucid-architecture-bad45f259a1d] and think of ways to 1) dissect it into pieces 2) delegate those pieces to Jobs. Maybe follow the Builder design pattern to build queries along with Repositories to facilitate the process. Below are a few examples:

  • Deterministic conditions can be delegated to jobs

if($this->lang == env('APP_LOCALE_LANG'){
    $query .= ", A.title as _display_name";
}else{
    $query .= ", A.title_" . $this->lang . " as _display_name",
}

pass the env('APP_LOCALE_LANG') as a param to the job

  • Dissect the query to pieces where each piece is built by a Job within the Operation

  • The following to be delegated to a Job that figures out pagination since it doesn't fall under the responsibility of getting the articles from the database, though called within this Operation

$next_page_url = "";
if(count($articles) == $limit){
    // probably can try loading another page
    $next_page = $page + 1;
    $next_page_url = $request->fullUrlWithQuery(['page' => $next_page]);
}else{
    $next_page_url = "";
}
  • This one requires a bit of refactoring
if($this->request->ajax()){
    return [
                'articles' => view('articles.ajax.show')->with(compact('articles'))->render(),
                'next_page_url' => $next_page_url,
                'article_count' => count($articles),
                'total_article_count' => $total_article_count
    ];
}

I suggest this to go in its own Operation, and the check for an Ajax request happens at the level of the Feature through a Job and calls a different Operation. Both Ajax and NonAjax operations would call the same Jobs (hence reusability of Jobs). The reason for this separation is the different concerns they tackle which isn't predictable by simply reading the Feature, especially that Ajax returns a different subset of the data since if it weren't Ajax you'd continue with the query.

  • Return the values expected
compact('articles','total_article_count','next_page_url','root_categories')

And it would be even better if this were a proprietary object instead of a loose array with keys, which is also an unpredictable response structure to know what's to be expected from whoever calls the Operation. i.e. ArticlesList object with methods to return articles(), rootCategories() etc... And if this structure repeats elsewhere in the code, it would be even better to have an interface that enforces these methods to be in the implementing class.

  • Eventually in the Feature you'd pass the received Object to the RespondWithViewJob

Please continue the conversation if you need clarification regarding the above.

Cheers.

2 likes
jvv8's avatar
Level 2

Thank you for clarifying the Lucid structure. One last aspect that I'm having trouble fitting into Lucid is numerous external API's which would generate api models after the API call. Then, I'd like to use these api models to interact and translate into eloquent or data models.

To clarify, in the past, I stuffed the code in 2 folders (both with architectural horns over them):

App\Custom\ExternalApis\Services

  • This folder contains an abstract model in Api.php and then implemented with AbcApi.php, XyzApi.php.

App\Custom\ExternalApis\Models

  • Thing1.php, Thing2.php ....

Then in the controllers, I would call the api to return the Models, then apply changes to my App\Models.

How would this structure fit into Lucid?

jvv8's avatar
Level 2

Side note: (the External API question is still the foremost struggle implementing within Lucid) That said: I would like to share, and for those in the know to chime in on, that I believe I successfully refactored a traditional Laravel Job and Notification after looking over the depreciated 2016 example, but I also wanted asynchronous delay that traditional Laravel Jobs use. Since Lucid is built on top of the Laravel structure, I knew it was possible, but not seen a code example, so I'll share mine since it took a bit of tweaking, I hope our esteemed Lucid creator agrees that this is the intended behavior within a Feature:

Within SomeFeature...

....
$some_object_to_send = $this->run(GetCitizensToNotifyJob::class,['city_id' => $city_id]);
$this->dispatch((new NotifyOfficersJob($some_object_to_send))->delay(env('JOB_DELAY_NOTIFY_OFFICERS')));

I created the Job under a notification domain:

> lucid make:job NotifyOfficersJob Notification

Then I made a subdirectory under src/Domains/Notification with the name of "Notifications" Here you can do typical notifications such as (pardon my non-tested pseudocode) :

<?php

namespace App\Domains\Notification\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\NexmoMessage;
use Auth;
use Framework\SomeObject;
use Carbon\Carbon;
class OfficerNotification extends Notification {
    use Queueable;
    protected $some_object;
    protected $some_locale;
   
    /**
     * Create a new notification instance.
     *
     * @return void
     */
    public function __construct($some_object, $some_locale)
    {
        
        $this->some_object = $some_object;
    $this->some_locale = $some_locale;
    
    }

    /**
     * Get the notification's delivery channels.
     *
     * @param  mixed  $notifiable
     * @return array
     */
    public function via($notifiable)
    {
        return ['mail','nexmo'];
    }

    /**
     * Get the mail representation of the notification.
     *
     * @param  mixed  $notifiable
     * @return \Illuminate\Notifications\Messages\MailMessage
     */
    public function toMail($notifiable)
    {
    //process your mail message
    return (new MailMessage)->subject($subject)->markdown('mail.officers.notify',['important_object' => $this->some_object);
     }

    /**
     * Get the Nexmo / SMS representation of the notification.
     *
     * @param  mixed  $notifiable
     * @return NexmoMessage
     */
    public function toNexmo($notifiable){
        
        $message = __('officers.alert_for_something',[$some_var],$this->some_locale)
        $url = route('officers.notify.sms',['slug' => $this->some_object->slug);
   
        return (new NexmoMessage)
                ->content($message);
    }
    /**
     * Get the array representation of the notification.
     *
     * @param  mixed  $notifiable
     * @return array
     */
    public function toArray($notifiable)
    {
        return [
            //
        ];
    }

App\Domain\Citizen\Job\GetCitizensToNotifyJob

namespace App\Domains\Notification\Jobs;

use Lucid\Foundation\QueueableJob;
use App\Domains\Notification\Notifications\OfficerNotification;
class NotifyOfficersJob extends QueueableJob
{
    protected $objects_to_send;
    /**
     * Create a new job instance.
     *
     * @return void
     */
    public function __construct($objects_to_send)
    {
        $this->objects_to_send = $objects_to_send;
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        foreach($this->objects_to_send as $object){
            $object->notify(new OfficerNotification($object));
        }
    }
}

I hope this was the right approach. It seems to be working. The bigger question is that I still don't know how to approach external api models within the Lucid architecture.

1 like
jvv8's avatar
Level 2

To amend my initial code under Lucid, here's a typical Feature that I'm using. Hopefully correctly ;) Pseudocode of course...

public function handle(Request $request)
    {
        
        $officer_collection = $this->run(GetOfficerNotificationCollectionJob::class,['id' => $request->id]);
        foreach($officer_collection as $object){
            $object->_total = $this->run(GetTotal::class,['object' => $object]);
        }
        $officer_collection = $officer_collection->sortBy('_total');
        $this->run(UpdateHistoryJob::class, ['id' => $request->id]);
        $notifications_to_send = $this->run(GetOfficerNotificationsToSendJob::class,['id' => $request->id]);
        $this->dispatch((new NotifyOfficersJob($notifications_to_send))->delay(env('JOB_DELAY_ALERT_NOTIFY')));
    
        $html = \View::make('officers.index',compact('officer_collection')->render();
        return json_encode(array('html'=> $html, 'officers' => $officer_collection),JSON_PRETTY_PRINT);
    }
1 like
Mulkave's avatar

@jvv8 sorry for the belated reply, I haven't received any notifications of your replies earlier. In case this happens again (that I take too long to reply) please feel free to contact us on the Lucid Slack channel: https://lucid-slack.herokuapp.com

To answer your latest question about a sample feature, here are a few notes:

  1. do not loop in features: that's what jobs are here for. Have jobs accept collections and do their work accordingly. This way, you may be able to optimise within the job (i.e. GetTotal might have a collection of officer_id and be able to fetch for all of them at once with one query instead of looping and issuing multiple queries)
  2. do not operate on data in features: $officer_collection->sortBy('_total') could be something you would do in several places in your code, instead of repeating that line in every feature that does it, have it in a Job and call that job from features instead
  3. Features only run jobs: and they do only that, View::make belongs to a job as well, that is specific to the view it renders (i.e. RenderOfficerIndexViewJob) so that in case you need to render this in multiple features OR the view template location changes, you'd only have to change in once place - the job - and it would change everywhere achieving centralisation.
  4. respond with jobs: it's quiet often that features respond, do that with a job to eliminate redundancy

Question

  1. Why did you need to use $this->dispatch()->delay() interesting to explore the possibility to run a delayed job!

Please or to participate in this conversation.