Skip to content

Instantly share code, notes, and snippets.

@wcalderipe
Last active November 27, 2022 15:15
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save wcalderipe/a117b1a2058a5a2910f8eee160ad8d7e to your computer and use it in GitHub Desktop.
Save wcalderipe/a117b1a2058a5a2910f8eee160ad8d7e to your computer and use it in GitHub Desktop.
A gist about what are the solution faces of controlling flow in side-effectful function pipelines.

Control flow in Clojure

A gist containing a problem example and different implementations based on what is being discussed at How are clojurians handling control flow on their projects?.

Feel free to leave a comment and feedback is welcome 😃

Examples in this Gist

(ns foo.control-flow-problem)
;;; PROBLEM
;;
;; Create a new user (simplified):
;; 1. Validate the email format (pure)
;; 2. Validate if the name is present (pure)
;; 3. Check if the email is already taken (side-effect)
;; 4. Save the user in the database (side-effect)
;; 5. If everything went well, return the created user
(defn fetch-user-by-email! [email]
(case email
"alice@mail.com" {:id "df19e9e0-20a4-4aa8-9e89-320a5edc1950"
:name "Alice"
:email "alice@mail.com"}
"bob@mail.com" (throw (ex-info "Error establishing a connection to the database." {}))
nil))
(defn uuid []
(str (java.util.UUID/randomUUID)))
(defn save-user! [user]
;; noop
(println "## Save user:" user)
(assoc user :id (uuid)))
@wcalderipe
Copy link
Author

wcalderipe commented Jul 18, 2021

;;; Option 1) Throwing exceptions in a thread macro

(defn option-1-validate-email
  [{:keys [email] :as ctx}]
  (if (re-matches #".+@.+\..+" email)
    ctx
    (throw (ex-info "Invalid e-mail format" {:email email}))))

(defn option-1-validate-non-blank
  [ctx field]
  (if (empty? (get ctx field))
    (throw (ex-info "Required field" {:field field}))
    ctx))

(defn option-1-fetch-user-by-email! [{:keys [email] :as ctx}]
  (assoc ctx :user (fetch-user-by-email! email)))

(defn option-1-check-email-availability
  [{:keys [user] :as ctx}]
  (if (nil? user)
    ctx
    (throw (ex-info "Email taken" {}))))

(defn option-1-make-user [{:keys [email name]}]
  {:email email :name name})

(defn option-1-create-user!
  [data]
  (try
    (-> data
        (option-1-validate-email)
        (option-1-validate-non-blank :name)
        (option-1-fetch-user-by-email!)
        (option-1-check-email-availability)
        (option-1-make-user)
        (save-user!))
    (catch Exception ex
      (println ex)
      {:reason (ex-data ex)})))

(comment
  (option-1-create-user! {:email "foo@mail.com" :name "foo"})
  (option-1-create-user! {:email "alice@mail.com" :name "alice"})
  (option-1-create-user! {:email "bob@mail.com" :name "bob"})
  ,)

@wcalderipe
Copy link
Author

wcalderipe commented Jul 18, 2021

;;; Option 2) Using didibus' `trylet` approach
;;
;; NOTE: pseudocode below!

(comment
  (defn option-2-validate-email
    [email]
    (if (re-matches #".+@.+\..+" email)
      email
      (throw (ex-info "Invalid e-mail format" {:email email}))))

  (defn option-2-validate-email
    [email]
    (if (re-matches #".+@.+\..+" email)
      email
      (throw (ex-info "Invalid e-mail format" {:email email}))))

  (defn option-2-check-email-availability
    [user]
    (when (not (nil? user))
      (throw (ex-info "Email taken" {}))))

  (defn option-2-create-user! [{:keys [email name] :as input}]
    (trylet
     [valid-email (option-2-validate-email email)
      valid-name  (option-2-validate-name name)
      user        (fetch-user-by-email! valid-email)
      _           (option-2-check-email-availability user)
      new-user    {:email valid-email :name valid-name}
      saved-user  (save-user! new-user)]
     {:status :ok :result saved-user}
     (catch Exception ex
       (println ex)
       {:status :error :result {:valid-user new-user}})))
  ,)

See also didibus' original version.

@pieterbreed
Copy link

Failjure version

(defn validate-email
  [{:as user-record
    :keys [email]}]
  (if (re-matches #".+@.+\..+" email)
    user-record
    (f/fail ["Invalid e-mail format" {:email email}])))

(defn find-db-user-by-email
  [email]
  (case email
    "alice@mail.com" {:id    "df19e9e0-20a4-4aa8-9e89-320a5edc1950"
                      :name  "Alice"
                      :email "alice@mail.com"}
    "bob@mail.com"   (throw (ex-info "Error establishing a connection to the database." {}))
    nil))

(defn validate-not-taken
  [{:as user-record
    :keys [email]}]
  (let [r (find-db-user-by-email email)]
    (if-not r user-record
            (f/fail ["Email already in use. {}"]))))

(defn save-user!
  [user-record]
  (assoc user-record :id (str (UUID/randomUUID))))


(defn create-user!
  [user-record]
  (try

    (let [result (f/-> user-record
                       (validate-email)
                       (validate-not-taken)
                       (save-user!))]
      (if (f/failed? result)
        {:status 400
         :body (f/message result)}
        {:status 200
         :body "OK"}))

    (catch Exception e
      (error e "Exception while attempting to create new user")
      throw e)))

@mjmeintjes
Copy link

mjmeintjes commented Jul 19, 2021

(ns foo.control-flow-problem.missionary
  (:require [missionary.core :as m]
            [clojure.string :as str]))

(defn fetch-user-by-email! [email]
  (case email
    "alice@mail.com" {:id    "df19e9e0-20a4-4aa8-9e89-320a5edc1950"
                      :name  "Alice"
                      :email "alice@mail.com"}
    "bob@mail.com"   (throw (ex-info "Error establishing a connection to the database." {}))
    nil))

(defn uuid []
  (str (java.util.UUID/randomUUID)))

(defn save-user! [user]
  ;; noop
  (println "## Save user:" user)
  (assoc user :id (uuid)))

(defn fetch-user-by-email-task [conn email]
  (ms/via
   ms/blk
   (Thread/sleep 1000) ;; simulate blocking io
   (fetch-user-by-email! email)))

(defn save-user-task [conn user]
  (ms/via
   ms/blk
   (Thread/sleep 1000) ;; simulate blocking io
   (println "## Save user:" user)
   (assoc user :id (uuid))))

(defn must-have [pred msg val]
  (when-not (pred val)
    (throw (ex-info msg {:val val}))))

(defn email? [s]
  (str/includes? s "@"))

(defn create-new-user-task [conn name email]
  (must-have string? "must have name" name) ;; throws exception
  (must-have email? "must have email" email) ;; throws exception
  (let [fetch-user (fetch-user-by-email-task conn email)
        save-user (save-user-task conn {:username name :email email})]
    (m/sp
     (must-have nil? "email already registered" (ms/? fetch-user))
     (ms/? save-user))))

(comment
  (ms/? (create-new-user-task nil "al" "alice@mail.com"))
  (ms/? (create-new-user-task nil "al" "bob@mail.com"))
  (ms/? (create-new-user-task nil "al" "new@mail.com")))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment