How can we directly call `(repo/get-cust-by-id id)`, instead of `((:get-cust-by-id sys-map) id)` when using component libraries?

With component libraries such as Component/Integrant, when a function has a dependency on part of the system state, we usually inject the dependency and put it in a map.
Assuming repo is a component with CRUD functions for customers, we would have something like:

;; to call (get-contact-by-id id), we do something like
(let [{:keys [repo]} sys-map]
  ((:get-contact-by-id repo) id))

I’d like to know if I do something like the example below to make the function call more natural, like (repo/get-contact-by-id id), what problems I might have in the long run.
Since my knowledge is still quite limited, I’m not sure about the consequences.
So, it would be good to get others’ opinions on this.

The basic idea is to have some dummy functions in the namespace, and
then alter the value of those vars with actual implementations (already injected functions with fewer arguments) during component initialization.When the system is ready, other modules can just call the functions as we normally would, without needing to look up values in a map.
The idea might be silly, but I’m just exploring if it can possibly work without many negative consequences.

Vars are mutated globally, but only once during initialization. Could this still be problematic somehow?

Regarding testing, I am not sure if it would make much harder. I think I can still test the internal version of the functions quite easily.

(ns contactappx.repository.contact-repo
  "Repository component for contact"
  (:require [donut.system :as ds]
            [next.jdbc :as jdbc]))

(defn find-contact-by-id [id]
  (throw (Exception. "Function not implemented.")))

(defn create-contact! [contact]
  (throw (Exception. "Function not implemented.")))

(defn ^:internal _find-contact-by-id [dsc id]
  "INTERNAL: Search contact by id using the given data source"
  (println "Hello world2"))

(defn ^:internal _create-contact! [dsc contact]
  "INTERNAL: Create a contact using the given datasource"
  nil)

(def ContactRepoComp
  {::ds/start
   (fn [{{:keys [dsc]} ::ds/config}]
     (alter-var-root
       #'contactappx.repository.contact-repo/find-contact-by-id
       (constantly (fn [id] (_find-contact-by-id dsc id))))
     (alter-var-root
       #'contactappx.repository.contact-repo/create-contact!
       (constantly (fn [id] (_create-contact! dsc id)))))
   ::ds/config {:dsc {}}})

;(alter-var-root
;  #'contactappx.repository.contact-repo/find-contact-by-id
;  (constantly (partial _find-contact-by-id dsc)))

(comment
  (def contact-repo (ds/start {::ds/defs {:repo {:contact-repo ContactRepoComp}}}))
  (println contact-repo)
  (find-contact-by-id 3)
  )

Edited: After reading this blog, I think I didn’t properly understand the reason why component libraries are used. Having the datasource as hidden state in the repository functions is already a bad idea I guess.

The answer is protocols, but they may change your code quite a bit.

Lets take your first two “API” functions and turn it into a protocol.

(defprotocol IContactRepo
  (find-contact-by-id [repo id] "a docstring")
  (create-contact! [repo contact]))

So, these now take an extra special first argument, being the thing that implements it.

You can then create implementations using either deftype, defrecord or reify.

(let [fake (reify IContactRepo
             (find-contact-by-id [_ id] {:id 1 :name "example"})
             (create-contact! [_ _] :ok))]
  
  (find-contact-by-id fake 1))

The component would create such an implementation, and the caller side would need to supply that thing to call it. This will likely cause some rather big changes in your code, but what you get is easily swappable implementations.

Mutating vars is bad and is not something I’d recommend doing.

1 Like