Extracting a Reusable Catalyst Model

| 5 Comments

The flexibility of the Catalyst offers a lot of value, though occasionally at a price. I've been impressed at the abstractions it offers, as I develop a couple of web applications that are growing in features and complexity.

One of those projects requires user registration. I've chosen an email verification system to help ensure that the system can notify users for the alerts they select. To make notifications work, I created a Catalyst model which sends emails. It looked something like this:

package Stockalyzer::Model::UserMail;
use strict;
use warnings;
use base 'Catalyst::Model::Adaptor';

package Stockalyzer::Mailer;

use MIME::Base64;
use Authen::SASL;
use Net::SMTP::SSL;

use base 'Mail::Builder::Simple';

sub send_verification
{
    my ($self, $c, $user) = @_;
    my $code              = $user->verification_code( force => 1 );
    my $email             = $user->email_address;
    my $register_link     = $c->uri_for(
        $c->controller( 'Users' )->action_for( 'verify' ),
        { VERIFY_USER_email => $email, VERIFY_USER_code => $code },
    );

    $self->send(
        subject   => ...,
        to        => ...,
        from      => ...,
        plaintext => ...,
    );
}

sub send_reset
{
    my ($self, $c, $user) = @_;
    my $code              = $user->reset_code;
    my $email             = $user->email_address;
    my $reset_link        = $c->uri_for(
        $c->controller( 'Users' )->action_for( 'reset_password' ),
        { USER_RESET_email => $email, USER_RESET_code => $code },
    );

    $self->send( ... );
}

sub send_feedback
{
    my ($self, $c, $params) = @_;
    $params->{path}       ||= 'no path found';
    $params->{details}    ||= '';
    $params->{type}       ||= 'severe type error';
    my $username            = $c->user ? $c->user->username : '(no user)';

    $self->send( ... );
}

1;

This is a standard Catalyst model. With the appropriate configuration (specifically setting the class attribute of the Model::UserMail model to Stockalyzer::Mailer, I can access the model from within Catalyst like:

sub send_feedback :Path('/send_feedback') :Args(0)
{
    my ($self, $c) = @_;
    my $method     = lc $c->req->method;

    return $c->res->redirect( '/users' ) unless $method eq 'post';

    my $params     = $self->get_params_for( $c, 'feedback' );
    $c->model( 'UserMail' )->send_feedback( $c, $params );

    return $c->res->redirect( $params->{path} || '/users' );
}

Then the time came to add a new feature—emailing users about event notifications. The event notification processing system runs every day in an automated process separated from the web application. It doesn't use Catalyst at all.

I was glad to have my mail configuration set up so easily within Catalyst, and I wanted to reuse that model for the offline mailer—keeping all of those actions in one place makes sense. That meant decoupling the mail sending actions from Catalyst altogether, both in the configuration system and in the arguments to the model's methods.

Changing the method signatures was easy. Instead of passing in the Catalyst request context, I changed them to pass in the URL:

sub send_verification
{
    my ($self, $uri, $user) = @_;
    my $code                = $user->verification_code( force => 1 );
    my $email               = $user->email_address;
    $uri->query_form( VERIFY_USER_email => $email, VERIFY_USER_code => $code );

    $self->send( ... );
}

The configuration was a little trickier. Configuration file parsers are like templating systems: everyone wants something a little different, and for every ten developers, twelve modules exist on the CPAN. In the interest of expedience (and with the knowledge that I'll change this later), I made the single place which instantiates my mailer object in the offline processing programs grab the existing configuration:

package Stockalyzer::App::Role::DailyUpdate;

use Modern::Perl;
use Moose::Role;
use Stockalyzer::Mailer;

has 'mailer', is => 'ro', lazy_build => 1;

sub _build_mailer
{
    my $self        = shift;
    my $config      = do 'stockalyzer_local.pl';
    my $mail_client = $config->{'Model::UserMail'}{mail_client};
    return Stockalyzer::Mailer->new( $mail_client );
}

As ugly as that is, the code already operates under the assumption that it's running from the root directory of the application. Besides that, any code which handles these updates already has to do something like this.

With those two changes made, I was able to send email from the offline process using the same underlying model that the web application uses. Extracting it took moments, and generalizing it to be independent of Catalyst took a couple of minutes.

Catalyst's tutorials suggest using an adaptor layer between your web application and your models, and they're correct—but even if you don't do things "right" from the start, being careful about defining the boundaries between layers of your system means that you can make these changes later.

5 Comments

I will have to re-read this to get everything that you are sharing.

Though i am curious that you are sending an email directly, rather than feeding it to a queue? arguably a local MTA is a queue, but i was more thinking something like beanstalkd.

I use the local MTA as a queue. It's already set up and working, and there's less to manage this way. I'm not sending hundreds of thousands of messages, and there's no need for immediate delivery: sometime in the next hour is sufficient.

I believe that the fact that Catalyst has models is a design mistake. The models don't abstract anything beside initialization - and here you needed to write your own initialization for the outside process - so why not just pass an already initialized model into the Catalyst controller in the normal Dependency Injection way? Initialization is a task for the DI container and it should not be coupled with web stuff.

That's an intriguing idea.

How would you manage lifecycle concerns (one model needs recreating per request, while one model has a pooled component, while another model needs initialization when the server starts)?

I wonder how much user code would end up messy and cluttered if these concerns were left to users: certainly Catalyst's configuration and initialization system is better than what I would have come up with if I were new to DI and IoC.

This is a good question - but actually Catalyst does not answer it - it only has application scope models (and ACCEPT_CONTEXT is just an ugly hack).

Modern Perl: The Book

cover image for Modern Perl: the book

The best Perl Programmers read Modern Perl: The Book.

sponsored by the How to Make a Smoothie guide

Categories

Pages

About this Entry

This page contains a single entry by chromatic published on July 16, 2012 9:00 AM.

Perl Shop Maturity Checklist: Perl-Specific Concerns was the previous entry in this blog.

Hurdles is the next entry in this blog.

Find recent content on the main index or look in the archives to find all content.


Powered by the Perl programming language

what is programming?