@Phill @seancorfield
Let’s consider an example of user entity with a focus on its address. In my previous approach, I would always reference the repository where the entity is defined and use namespaced keywords.
(ns myapp.spec.user
(:require [clojure.spec.alpha :as s]))
(s/def ::name string?)
(s/def ::address string?)
(s/def ::user (s/keys ::req [::name ::address]))
;; construct a user entity somewhere
(require '[myapp.spec.user :as user])
(let [user-name "iamgroot"
user-address "1 Park Ave, NY"
user {::user/name user-name ::user/address user-address}]
(swap! *users assoc user-name user))
;; update the address somewhere else
(require '[myapp.spec.user :as user])
(let [user-name "iamgroot"
new-address "2 Union Sq, SF"]
(swap! *users assoc-in [user-name ::user/address] new-address))
Consider now a new requirement that
- user can register up to 5 addresses
- user can choose a color for each address
- user can update the priority of the addresses by UI dragging.
This necessitates refactoring. The following is just one way of doing it.
(ns myapp.spec.user.address
(:require [clojure.spec.alpha :as s]))
(s/def ::index (s/int-in 0 6)
(s/def ::color string?
(s/def ::address string?)
(s/def ::entity (s/keys ::req [::index ::address] :opt [::color]))
(ns myapp.spec.user
(:require [clojure.spec.alpha :as s]
[myapp.spec.user.address :as address]))
(s/def ::name string?)
(s/def ::addresses (s/coll-of ::address/entity)
(s/def ::user (s/keys ::req [::name ::addresses]))
;; construct a user entity somewhere
(require '[myapp.spec.user :as user])
(require '[myapp.spec.user.address :as address])
(let [address {::address/index 0
::address/color "orange"
::address/address "1 Park Ave, NY"}
user {::user/name "iamgroot"
::user/addresses [address]}]
(swap! *users assoc "iamgroot" user))
;; update a primary address somewhere else
(require '[myapp.spec.user :as user])
(require '[myapp.spec.user.address :as address])
(let [user-name "iamgroot"
new-address "2 Union Sq, SF"]
(swap! *users assoc-in [user-name ::user/addresses 0 ::address/address] new-address))
In an alternative approach that is more domain-driven than data-driven, I would’ve first written
(ns myapp.domain.user
(:require [clojure.spec.alpha :as s]))
;; * Specs
(s/def ::name string?)
(s/def ::address string?)
(s/def ::user (s/keys ::req [::name ::address]))
;; * APIs
(defn new [name address]
{::user/name name ::user/address address})
(defn set-address [user new-address]
(assoc user ::user/address new-address))
;; There should be as many APIs here as the domain requires.
;; construct a user entity somewhere
(require '[myapp.domain.user :as user])
(let [user-name "iamgroot"
user-address "1 Park Ave, NY"
user (user/new user-name user-address)]
(swap! *users assoc user-name user))
;; update the address somewhere else
(require '[myapp.domain.user :as user])
(let [user-name "iamgroot"
new-address "2 Union Sq, SF"]
(swap! *users update user-name user/set-address new-address))
This is definitely more code than before, but refactoring becomes more localised.
(ns myapp.domain.user.address
(:require [clojure.spec.alpha :as s]))
(s/def ::index (s/int-in 0 6)
(s/def ::color string?
(s/def ::address string?)
(s/def ::entity (s/keys ::req [::index ::address] :opt [::color]))
(ns myapp.domain.user
(:require [clojure.spec.alpha :as s]
[myapp.domain.user.address :as address]))
;; * Specs
(s/def ::name string?)
(s/def ::addresses (s/coll-of ::address/entity)
(s/def ::user (s/keys ::req [::name ::addresses]))
;; * APIs
(defn new [name address]
(let [address-entity {::address/index 0
::address/color "orange" ;; default-color
::address/address address}]
{::user/name name
::user/addresses [address]))
(defn set-primary-address [user new-address]
(assoc-in user [::user/addresses 0 ::address/address] new-address))
;; update a primary address somewhere else
(require '[myapp.domain.user :as user])
(let [user-name "iamgroot"
new-address "2 Union Sq, SF"]
(swap! *users update user-name user/set-primary-address new-address))
Here, most of refactoring takes place in the myapp.domain repository. No change is needed in the repository where a user entity gets created. I needed to touch the repository where the setter API is called because of its renaming, but this is a trivial change.
What I wanted to highlight in this made-up example is that if you strive for domain driven programming and if you reference namespaced keywords across multiple keywords, you may end up leaking the implementation details of your domain entities.
@seancorfield
I agree with you. This is a tradeoff. The tradeoff between the verbosity of wrapping/hiding and the ease of refactoring. If it is an environment where the design of domains entities is more ore less stable, I wouldn’t avoid this type of encapsulation. But, in the environment where enriching and improving domain semantics drives business and unexpected refactoring is norm rather than exception, which is where I find myself, I am willing to pay upfront some verbosity. How costly will this price be? I don’t know. But one thing I know for sure is that I don’t need setters and getters for all entity keywords. The number of API functions should be no more than the domain requires.