http://seancorfield.github.io for newer blog posts." />

An Architect's View

CFML, Clojure, Software Design, Frameworks and more...

An Architect's View

Real World Clojure - email status tracking

November 2, 2011 · 3 Comments

I covered email tracking a little when I wrote about how we parse PowerMTA account files but I didn't go into what we actually do with our delivered / bounced data. We track the current status of an email as: updated, valid, bouncing or invalid. An email address that bounces a certain number of times is marked as invalid. An email address is considered valid whenever we record an email view (via an embedded image) or the member clicks a link we sent them in an email. An email is updated when a member first registers or when they change their email address. So we have a series of email "events" and we need to handle changes of state, based on those events.

Clojure makes it very easy to write a state machine with "multimethods". In most OOP languages, we're used to seeing polymorphism implemented using inheritance, where the actual method invoked depends on the runtime type of the object involved. With Clojure's multimethods, you can dispatch based on the type of the first argument - which is equivalent to standard polymorphism in most OOP languages - but multimethods go beyond that by allowing you to dispatch off any function of any combination of arguments!

Back to our example, we have two arguments: event and current-state (which is a map fetched from our datastore). The events are :updated, :validated, :bounced and the six state changes we need are:

  • :updated + any state -> state = "updated"
  • :validated + any state -> state = "valid"
  • :bounced + "valid" or "updated" -> state = "bouncing", record :bouncingSince timestamp
  • :bounced + "bouncing" -> record additional bounce, change to "invalid" if too many bounces
  • :bounced + "invalid" -> no change

Clojure lets us treat this polymorphically by writing a dispatching function (that maps the arguments to something we can dispatch on) and then writing a method for each case we care about:

(defmulti process-email
  (fn [event record] 
    (if (= :bounced event) 
      [event (keyword (:status record))] 
      event)))

;; --- start process-email ---
(defmethod process-email [:bounced :bouncing] [_ record]
  (process-bounce (update-in record [:bounces] inc)))

(defmethod process-email [:bounced :invalid] [_ record]
  (process-bounce record))

(defmethod process-email [:bounced :updated] [_ record]
  (process-bounce (merge record {:bouncingSince (date/today), :bounces 1})))

(defmethod process-email [:bounced :valid] [_ record]
  (process-bounce (merge record {:bouncingSince (date/today), :bounces 1})))

(defmethod process-email :updated [_ record]
  (merge record {:lastUpdated (date/today), :status "updated"}))

(defmethod process-email :validated [_ record]
  (merge record {:lastValid (date/today), :status "valid"}))
;; --- end process-email ---

We start by declaring the multimethod and the dispatch function. For a bounced event, it returns a pair of the event and the current status. For other events, it returns just the event (because we don't care about the current status for those events). Then we define the six cases we care about. process-bounce returns an updated email status record:

(defn- process-bounce [record]
  (merge record {:lastBounce (date/today), 
                 :status (if (>= (:bounces record) limit)
                           "invalid"
                           "bouncing")}))

We record the most recent bounce and change the status when the bounce limit is reached. Now we can just call (process-email event current-status) to get the new status and save that back to the datastore:

(crud/save-row :emailStatus new-record identity :email)

This form of save-row specifies the primary key as :email (as opposed to the default of :id) and that no transformation is needed to create the primary key (identity is passed as the "key generator" function) - so it operates as an update.

Multimethods allow us to write concise functions that perform specific tasks without requiring distracting conditional logic (in this case we'd need a combination of switch and if). Another example of how expressive Clojure can be.

This brings us to the end of my initial series of "Real World Clojure" posts. I'll probably add more in future as we continue to expand our use of Clojure at World Singles.

Tags: clojure

3 responses so far ↓

  • 1 Ken // Nov 3, 2011 at 11:11 AM

    Thanks for the series, Sean.

    Some of what you're writing about is obviously back-end code, running independently of the CFML tier.

    But for the pieces relevant to the user-facing application: you end up compiling your Clojure code, ultimately placing each .jar on a path accessible to Railo, then calling methods as needed in your CFML code?
  • 2 Jakub Holy // Nov 4, 2011 at 2:38 AM

    Hi Sean, thank you so much for the series! For me as a person learning Clojure it's very interesting and inspirational to see how it's used on a real world project. And finally I can understand for are multimethods good for :-) I'm looking forward to your future posts.

    Regards, Jakub
  • 3 Sean Corfield // Nov 4, 2011 at 4:57 PM

    @Ken, we only compile one part of the Clojure code - the custom log4j database appender - and that's because log4j requires a compiled class (see my RWC post on that).

    The choice is then to bundle your Clojure source into a JAR and put it on the classpath or to adjust the classpath to include the Clojure project's src and lib folders.

    Inside the CFML code, the Clojure runtime is used to load (& compile-on-demand) the source files (using my cfmljure project).

Leave a Comment

Leave this field empty