I had a discussion about this with a few others on the slack. One idea that emerged (there are many, and I might even have a second answer describing another approach) is the following:
- Put value specs together in a shared namespace, i.e,
specs.clj
- Put structural specs in the namespaces that depend on that structure, i.e., inside the namespace itself like inside
ships.clj
and stars.clj
or in their corresponding spec namespace like ships_specs.clj
and stars_specs.clj
- Put type specs together in a shared namespace, maybe even a shared library since you might want to use this one across even unrelated projects, such as
type_specs.clj
or common_specs.clj
The tricky part of this approach is deciding exactly what spec fit which category. I'm still struggling a little with this, but the following explanations help a lot.
What's a value spec?
A value spec is a spec for data that is non-divisible.
Anything that isn't a collection would be a value spec, such as a string, a number, a keyword, etc.
Sometimes, it could be a collection as well, but where in the context of your app, you never break apart that collection. This is where it gets a little fuzzy, and I recommend maybe starting without any collection as value specs, but it would be for example a point
: {:x 0-255 :y 0-255}
. If you'll never represent a point any differently, such as never having it be: [:x 0-255 :y 0-255]
or never have a function that only takes :x
coordinates without their corresponding :y
for example, then it qualifies as a value spec, otherwise it doesn't.
A value spec is also meaningful to your app. It means that the name of the value spec has meaning in your application. For example, you could have (s/def ::first-name string?)
and (s/def ::last-name string?)
. When you look at those, you see they are both just of spec string?
, but in your app, it is meaningful to distinguish those string?
that are first names to those that are last names. Thus all of your value specs should have meaningful names like that, which are part of the ubiquitous language that you use when discussing the domain of your app. If you explain to me what your app does, you wouldn't say it takes strings and stores them in a DB, you'd say it takes customer first names and last names.
Thus, because they are domain concepts, with a domain wide meaning, they go in your shared spec namespace.
One clarification here, if you realize you have more than one type of first-name, for example, you realize you keep talking about customer-first-name and animal-first-name. It means you need to have two value spec, one for each. Basically, if there are sub-contexts within the context of your app, where the same name is used with a different implicit meaning, you need to make that explicit.
What's a structural spec?
A structural spec is a temporary arrangement of value specs into some collection or structure. What Rich Hickey calls: "information traveling together". Sometimes, you have a function that needs three inputs, and it decides to take those on a map together, that is a structural spec, it is specific to that function and how it wants the data to be arranged during its execution, so it goes alongside its namespace.
Many more things are structural specs then you'd think at first. For example, you might think a User is a value spec. But dig a bit deeper, do you always have exactly the same information put together in exactly the same structure absolutely everywhere your app makes use of a user ? Very quickly you realize, a user sometimes exists without a date-of-birth, or without an associated shopping-list, etc. Sometimes, a user has a password hash on it, and other times it doesn't.
That's why I recommend you first try this with the rule that all collections be structural specs defined in their respective namespace (or accompanying spec namespace) which makes use of it. And only have value specs be non-collection. That really trains you into this model of speccing things, and once you get a good grasp, the few collections that are good candidates for value specs will be more easily for you to recognize.
So, things like s/keys, s/map-of, s/tuple, s/coll-of, etc. are ll most likely going to be structural specs.
In general, you don't need to spec all structures of your app. Start with only the inputs and outputs at the boundary. So user provided data, data you save to a file or a DB, data sent and returned to/by your APIs, etc. This is where it is most critical that the data be validated. Feel free to expand that to more and more internal things if you want better safety that your code works, or to document things.
What's a type spec?
Lastly, a type spec is specs whose name isn't meaningful to your app. Remember how I said value specs must have meaningful names, well, when a value spec doesn't have a meaningful name, it is a type spec. For example, we had ::first-name
and ::last-name
value specs, and they were both string?
. Well say you want something that is only a non blank string? and you want ::first-name
and ::last-name
to both be of that type? Well you can create a type spec: (s/def ::non-blank-string (complement str/blank?))
.
Sometimes in the type_specs
namespace, instead of putting specs, you could put spec creating functions/macros as well, utils that help you define type specs. Such as say a (string-of-length len)
which would return a spec validating that a string is of a given length.
That's the point of type specs and its namespace. Since those are basically domain agnostic, you could see yourself putting them in a library, which you reuse across projects.
How does it all look like together?
Well, I won't go over the entire spacewars code base, but for example, using this style of spec modeling, you might have:
- value_specs.clj
(s/def ::x number?)
(s/def ::y number?)
(s/def ::velocity-1d number?)
(s/def ::velocity-2d ::type/num2tuple)
(s/def ::age number?)
(s/def ::direction number?)
(s/def ::romulan-state #{:invisible :appearing :visible :firing :fading :disappeared})
(s/def ::battle-state #{:no-battle :flank-right :flank-left :retreating :advancing})
(s/def ::battle-state-age number?)
Basically you'd put all value specs in value_specs, if one name means something different in a different context like for state
and velocity
you make the context explicit in its name.
- klingons.clj
(s/def ::klingon (s/keys :req-un [::value/x ::value/y ::value/velocity2d ::value/battle-state-age ::value/battle-state ...] ...)
And so forth
- type_specs.clj
(s/def ::num2tuple (s/tuple number? number?))
Pros/Cons
Pros
- Forces you to understand the different names in your domain and their contexts
- Reuse of value specs across namespaces that make use of them, instead of defining them over and over again
- Possibly avoids having two names for the same exact thing
- Prevents you from accidentally coupling yourself to a particular instance of a structure
- Allows functions to freely define the structure they prefer to operate over
- Reuse of common type specs and spec constructors/utils across projects
Cons
- You could accidentally use the same value spec for two things that actually were not the same, and one day you realize you need to distinguish them, and thus need to factor out the value spec and break it in two.
- You might end up with duplicate structure specs, but this is intentional, as we assume those are more likely to turn out one namespace needs more or less keys or needs it in a different collection.