Message Functions

- jnorton - Element 84

One common functionality for software is the need to provide human readable text messages. This is true of native applications, web applications, and many back-end systems. For applications with a user interface there is often a requirement that these messages be localized, that is, provided to the user in the user’s native language. For this reason, best practices dictate that developers avoid hard coding these messages and use some sort of indirection to look them up. Typically frameworks provide a mechanism to facilitate this, such as NSLocalizedString for iOS apps or the I18n gem for Rails apps.

Back-end applications often do not need localization as the messages are are not intended for a large audience. For this reason messages are often hard coded strings that are embedded throughout the code base, especially error messages that are embedded directly in the error handling code. While this is certainly convenient for developers, there are considerable advantages to avoiding this practice.

Consider the following Clojure code that returns a map with an HTTP status code and an error message if the method fails for some reason.

1
2
3
4
5
6
7
(defn my-function
  "This function does something cool and returns a map with an HTTP status code
  and possibly an error message."
  [input]
  (if (some-other-function input)
    {:status 200}
    {:status 400 :error "bad data"}))

Like all good developers we write a test for our function:

1
2
3
4
5
6
7
8
9
(deftest test-cool-function
  (let [good-input "good input"
        bad-input "bad input"]
    (are [input exp-status exp-msg]
      (let [{:keys [status error]} (my-function input)]
        (and (= exp-status status)
             (= exp-msg error)))
      good-input 200 nil
      bad-input 400 "bad dta")))

We run our test and immediately find a problem:

1
2
3
4
5
6
FAIL in (test-cool-function) (core_test.clj:8)
expected: (let [{:keys [status error]} (my-function bad-input)] (and (= 400 status) (= "bad dta" error)))
actual: false
 
Ran 1 tests containing 2 assertions.
1 failures, 0 errors.

We realize our mistake and fix the typo in our test (you saw it, right?). Not a big deal, but wouldn’t it be better if the compiler could catch errors like that?

Now suppose somewhere down the line we decide to change the error message to “Invalid input”. Hopefully we remember to update our test. But what if we have other tests that rely on the output of this function, perhaps at the integration level? We can use some sort of global search and replace to update everything at once, but that can be problematic. We might end up changing something we didn’t intend to change. Fixing them one-by-one isn’t much better.

We can avoid these kind of problems if we use some indirection and create vars for our messages:

1
2
(def invalid-input-message
  "Invalid input.")

Our updated function and test become

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(defn my-function
  "This function does something cool and returns a map with an HTTP status code
  and possibly an error message."
  [input]
  (if (some-other-function input)
    {:status 200}
    {:status 400 :error invalid-input-message}))
 
(deftest test-cool-function
  (let [good-input "good input"
        bad-input "bad input"]
    (are [input exp-status exp-msg]
      (let [{:keys [status error]} (my-function input)]
        (and (= exp-status status)
             (= exp-msg error)))
      good-input 200 nil
      bad-input 400 invalid-input-message)))

Now if we make a typo with the var name the compiler will catch our mistake before we even run our test. Even better, we can change our error message however we like and any tests that rely on it will still work.

This is a big improvement over hard coded strings, but we can do better. Instead of defining our error message as a var, we can create a function that will return the error message. Then we can generate dynamic messages that provide detailed information:

1
2
3
4
(defn invalid-input-message
  "Generates an error message identifying the bad input."
  [input]
  (str "Invalid input [" input "]"))

Now we update our function and the test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(defn my-function
  "This function does something cool and returns a map with an HTTP status code
  and possibly an error message."
  [input]
  (if (some-other-function input)
    {:status 200}
    {:status 400 :error (invalid-input-message input)}))
 
(deftest test-cool-function
  (let [good-input "good input"
        bad-input "bad input"]
    (are [input exp-status exp-msg]
      (let [{:keys [status error]} (my-function input)]
        (and (= exp-status status)
             (= exp-msg error)))
      good-input 200 nil
      bad-input 400 (invalid-input-message input))))

We still derive the benefits of using a symbol (which the compiler can check) and we don’t have to duplicate code to construct dynamic messages everywhere they are used.

Because functions are first-class types in Clojure, we can store them in vectors or maps as well as pass them into other functions. We can change our function to accept the message function as a parameter, then call this function when there is an error:

1
2
3
4
5
6
7
(defn my-function
  "This function does something cool and returns a map with an HTTP status code
  and possibly an error message."
  [input error-msg-fn]
  (if (some-other-function input)
    {:status 200}
    {:status 400 :error (error-msg-fn input)}))

This allows the caller to dictate which message function to use. We can create many different message functions that take the same parameters and choose from any of them when calling my-function.

Better yet, we can take advantage of Closure’s dynamic dispatch and create multi-methods for our message functions. We can change my-function to take a context that contains state (like which language to use for messages – because maybe we do want to localize our messages).

1
2
3
4
5
6
7
(defn my-function
  "This function does something cool and returns a map with an HTTP status code
  and possibly an error message."
  [context input error-msg-fn]
  (if (some-other-function input)
    {:status 200}
    {:status 400 :error (error-msg-fn context input)}))

Then we can create a dispatch function based on this context

1
2
3
(defmulti invalid-input-message
  (fn [context input]
    (:lang context)))

and methods based on each language we want to provide:

1
2
3
4
5
6
7
(defmethod invalid-input-message :english
  [context input]
  (str "Invalid input [" input "]"))
 
(defmethod invalid-input-message :esperanto
  [context input]
  (str "Nevalida enigo [" input "]"))

We could even define protocols based around our messaging and dispatch our message function calls based on types. Using message functions provides us with opportunities we just can’t get with hard coded or var messages.

The examples I have given are all Clojure examples, but any language that supports first class functions and dynamic dispatch can be used to implement these techniques. Even languages that don’t have these features will benefit from being able to dynamically construct messages based on input parameters.

So next time you start to hard code a message in your library or application, give some thought to using message functions instead. Sacrificing a little bit of temporary convenience can go a long way toward providing a cleaner, more flexible code base.