Best practice CFWheels (or RoR, or any other framework) sending email

228 Views Asked by At

From my research, it seems there's a general consensus that sending email is something that belongs in the Controller. So for example, if I have a signup form for People, when a user submits a signup form, I would validate the Person, and then once the Person is saved, the People controller would do more stuff - for example, send an email confirmation message, send a welcome email with attachments, and send an email to an admin.

That's fine until there's another part of the application that ALSO creates people. Easy enough to call the Person model and create(), but what about all that extra stuff that might (or might not!) need to happen... should the developer have to remember to do all that stuff in any controller of the application? How do you keep your code DRY in this case?

My inclination was to make an "after create" filter in the Person model, and perhaps add an optional parameter that would disable sending of email when a Person is created, but testing becomes a nightmare, etc.

How do you keep from having all the other parts of the application have to know so many rules about creating a new Person? I want to refactor, but not sure which direction to go.

4

There are 4 best solutions below

11
Sergio Tulentsev On BEST ANSWER

So, you create users in controllers and you create them somewhere else, and you want to keep DRY? This calls for a builder!

class UserBuilder
  attr_reader :user_params, :user, :send_welcome_email

  def initialize(user_params, send_welcome_email: true)
    @user_params = user_params
    @send_welcome_email = send_welcome_email
  end

  def build
    instantiate_user
  end

  def create
    instantiate_user

    before_create(user)
    return false unless user.save
    after_create(user)
  end

  private

  def instantiate_user
    @user ||= User.new(user_params)
  end

  def before_create(user)

  end

  def after_create(user)
    # or do whatever other check you can imagine
    UserMailer.welcome_email(user) if send_welcome_email 
  end
end

Usage:

# in controller
UserBuilder.new(params[:user]).create

# somewhere else
user_params = { email: '[email protected]' }
UserBuilder.new(user_params, send_welcome_email: false)

RE additional info

Also, CFWheels only provides sendEmail() for controllers, not models

This is ruby, it has built-in email capabilities. But fine, I'll play along. In this case, I would add some event/listener sauce on top.

class UserBuilder
  include Wisper::Publisher

  ... 

  def after_create(user)
    # do whatever you always want to be doing when user is created

    # then notify other potentially interested parties
    broadcast(:user_created, user)
  end
end

# in controller
builder = UserBuilder.new(params[:user])
builder.on(:user_created) do |user|
  sendEmail(user) # or whatever
end
builder.create
5
Alex On

This is probably better suited for StackExchange's Software Engineering.

You have to consider that sending follow-up emails (like a welcome message) must not be linked to the creation of a new person. A function should always do just one thing and shall not depend on or require other functions to execute.

// Pseudocode
personResult = model.createPerson(data)
if personResult.Successful {
    sendWelcomeMessage(personResult.Person)
    sendAdminNotification(personResult.Person)
} else {
    sendErrorNotification(personResult.Debug)
}

Your goal is to decouple every step in a process flow, so you can easily change process flow details without needing to change functions.

If your process flow occurs in different locations in your application, you should wrap the process in a function and call it. Imagine the above code in a function named createPersonWithNotifictions(data). Now you are flexible and can easily wrap an alternative person creation flow in another function.

3
BKBK On

Just add a Mail entity to the model. It will have the responsibility of sending a message of a particular type (Welcome, Notification, Error) to Person, Admin or Debugger. The Controller will then have the responsibility of collaborating with entities such as Person, Mail and Message to send the respective types of messages.

2
Franc Amour On

My final solution in CFWheels was to create a model object called "PeopleManager". I hated using "manager" in the name at first, but now it makes sense to me. However, if this fits a specific design pattern/name, i'm all ears.

Basically, the convention in my application will be that all the various modules that want a "new person" will need to go through the manager to get it. In this way, it is easy to control what happens when a new person is created, and for which areas of the application. For example, if a user creates a new Comment and their email address is not already a record in the People table, the Comment controller will be requesting a new person. When it makes that request of the PeopleManager, it is in that object that the business logic "When a new person is created from a Comment, send out a welcome message" will exist. While I'm not sure yet how the method names will pan out, so far I am considering going the route of "getNewPersonForComment"... and each module will have it's own types of calls. Repeated code in the PeopleManager (i.e. several of these distinct functions may all use the same steps) will be abstracted into private methods.

This provides a layer between modules and the data access layer, and also keeps the DAO type wheels objects from getting too "smart" and straying from the single responsibility principle.

I haven't worked out all the details yet. Especially whether or not a controller that will be using a Manager should be explicitly 'handed' that manager or whether simply treating the Manager as an object like any other (in cfwheels, model("PeopleManager").doSomething() is sufficient.

With regards to the differences between RoR and CFWheels when it comes to emailing, CFW does not have the concepts of a "Mailer" like RoR does, and the sendMail() function is a controller-only function. So, I have basically developed a mail queue feature that gets processed asynchronously instead, and will (hopefully) act much like the RoR analogue. This may become a CFWheels plugin. I have a feeling the need for this type of workaround revolves around the fact that controllers in CFW cannot easily call other controllers, and the debugging becomes nightmare-ish.

It's still evolving, and I welcome comments on my solution.