top | item 45824240

(no title)

codemonkey-zeta | 3 months ago

To be clear, there's nothing wrong with your approach, and many people implement systems exactly the way you are describing in Clojure using Records (which are Java classes).

  (defrecord UserLoginView [email password])

  ;; DIFFERENCE: compile-time validation
  (defn login [^UserLoginView view]
    (authenticate (:email view) (:password view)))

  ;; Usage
  (let [user-data {:user/email "user@example.com"
                   :user/password-hash "hash123"
                   :user/address "123 Main St"
                   :user/purchase-history []}

        ;; DIFFERENCE: construct the intermediary data structure - ignore extra stuff explicitly
        login-view (->UserLoginView
                     (:user/email user-data)
                     (:user/password-hash user-data))]
    (login login-view))


I prefer not to work this way though. The spec-driven alternative could be:

  (require '[clojure.spec.alpha :as s])
  (require '[clojure.spec.gen.alpha :as gen])

  (s/def :user.login/email string?)
  (s/def :user.login/password-hash string?)
  (s/def :user.login/credentials
     (s/keys :req [:user.login/email ;; spec's compose
                   :user.login/password-hash]))

  (defn login [credentials]
    ;; DIFFERENCE: runtime validation
    {:pre [(s/valid? :user.login/credentials credentials)]}
    (authenticate (:user.login/email credentials)
                  (:user.login/password-hash credentials)))

  (let [user-data {:user.login/email "user@example.com"
                   :user.login/password-hash "hash123"
                   :user/address "123 Main St"  
                   :user/purchase-history []}]
    ;; DIFFERENCE: extra data ignored implicitly
    (login user-data))

  ;; Can also pass a minimal map
  (login {:user.login/email "user@example.com"
          :user.login/password-hash "hash123"})

  ;; or you can generate the data (only possible because spec is a runtime construct)
  (let [user-data 
        (gen/generate (s/gen :user.login/credentials)) ; evaluates to #:user.login{:email "cWC1t3", :password-hash "Ok85cHMP5Bhrd4Lzx"}
        ]
    (login user-data))

The drawbacks of Records are the same for Objects - Records couple data structure to behavior (they're Java classes with methods), while spec separates validation from data, giving you:

- generative testing (with clojure.spec.gen.alpha/generate)

  You say "it makes sense to unit test logins using every conceivable variation of `UserLoginView`", well, with spec you can actually *do that*:

  (require '[clojure.test.check.properties :as prop])
  (require '[clojure.test.check.clojure-test :refer [defspec]])

  (defspec login-always-returns-session 100
    (prop/for-all [creds (s/gen :user.login/credentials)]
      (let [result (login creds)]
        (s/valid? :user.session/token result))))

  This is impossible with Records/Objects - you can't generate arbitrary Record instances without custom generators.
- function instrumentation (with clojure.spec.test.alpha/instrument)

- automatic failure case minimization (with clojure.spec.alpha/explain + explain-data)

- data normalization / coercion (with clojure.spec.alpha/conform)

- easier refactoring - You can change specs without changing data structures

- serialization is free - maps already serialize, whereas you have to implement it with Records.

Plus you get to leverage the million other functions that already work on maps, because they are the fundamental data structure in Clojure. You just don't have to create the intermediate record, let your data be data.

discuss

order

No comments yet.