Component interfaces give many benefits:
-
Consistency. Components can only be accessed through their interface, making them easy to find, use, and reason about.
-
Encapsulation. You can change a component’s implementing namespaces without breaking the interface contract.
-
Composability. All components have access to all other components via their interfaces, and you can replace them as long as they use the same interface.
So what is an interface, and what is it good for?
An interface in the Polylith world is a namespace named interface that usually lives in one, but sometimes several, sub-namespaces within a component.
Looking at our example user component, if you exclude the top namespace, in Polylith we think of its interface namespace as user.interface.
The interface defines def, defn, or defmacro statements which form the contract that it exposes to other components and bases.
Let’s pretend we have the interface namespace user.interface containing the functions fun1 and fun2 and that two components user and admin implement this interface, e.g,:
▾ myworkspace
...
▾ components
▾ user # (1)
▾ src
▾ com
▾ mycompany
▾ user # (2)
interface.clj
fun1
fun2
...
▾ admin # (3)
▾ src
▾ com
▾ mycompany
▾ user # (4)
interface.clj
fun1
fun2
...
...-
usercomponent -
implements
userinterface -
admincomponent -
also implements
userinterface
Because we are free to edit the interface.clj file for both user and admin, they can get out of sync if we are not careful.
Luckily, the poly tool will help us keep them consistent and complain if they differ when we run the check, info or test commands:
| Check | Example mismatch | To protect against |
|---|---|---|
Number of arguments |
|
Incompatible signatures |
Argument order |
|
Accidental argument call order mismatches |
Type hints |
|
Incompatible types and type confusion |
Functions and macros |
|
Incompatible composability: functions are composable, and macros are not |
We often choose to have a single interface namespace in a component, but dividing the interface into several sub-namespaces is possible.
To do so, create an interface package (directory) with the name interface at the component root and then add the sub-namespaces.
You can find an example in Polylith itself, where the util component divides its interface into several sub-namespaces:
util
└── interface
├── color.clj
├── exception.clj
├── os.clj
├── str.clj
└── time.cljDividing an interface like this can be handy to group and sub-group the interface functions.
One typical usage is to place clojure specs in its own spec sub-namespace.
You can see an example of this in the RealWorld example app, where the article component has both an article.interface and an article.interface.spec sub-interface.
Here’s an example of the sub-interface usage from the RealWorld example app handler namespace in its rest-api base:
(ns clojure.realworld.rest-api.handler
(:require ...
[clojure.realworld.user.interface.spec :as user-spec]
...))
(defn login [req]
(let [user (-> req :params :user)]
(if (s/valid? user-spec/login user)
(let [[ok? res] (user/login! user)]
(handle (if ok? 200 404) res))
(handle 422 {:errors {:body ["Invalid request body."]}}))))|
Tip
|
Every time you are tempted to split up an interface into sub-namespaces, instead consider splitting the component into smaller components! |
The most common way of structuring code in components is to delegate calls from the interface to one or more implementing namespaces.
There is one case where putting the implementation directly in the interface can be worth considering: if the functions are one-liners or tiny. You can see an example of this in the path-finder component of the Polylith workspace.
Ultimately, it’s up to you to do what you think is best.
Our experience has shown us many advantages to keeping interfaces tiny and having them only expose what is needed.
So far, we have only used functions in the interface.
Polylith also supports having def and defmacro statements in the interface.
There is no magic here; just include the definitions you want, like this:
(ns se.example.logger.interface
(:require [se.example.logger.core :as core]))
(defmacro info [& args]
`(core/info ~args))…which delegates to:
(ns se.example.logger.core
(:require [taoensso.timbre :as timbre]))
(defmacro info [args]
`(timbre/log! :info :p ~args))This list of tips makes more sense when you have used Polylith for a while, so bookmark this section for later:
-
The interface docstrings should focus on what problem each function/macro solves, while the implementation docstrings can focus on concrete details.
-
Consider sorting interface namespace functions in alphabetical order for easy lookup. Order functions in implementation namespaces freely.
-
The interface can expose the entity’s name, e.g.,
sell [car], while the implementing function can expose specific usage via destructuring, e.g.,sell [{:keys [model type color]}]. -
It sometimes makes sense for a multi-arity function in an interface to delegate to a single arity function in the implementing namespace:
(defn foo ([a b c] (some-impl/foo a b c) ([a b] (foo a b nil)))
-
It sometimes makes sense for a variadic functions in an interface to delegate to function in the implementing namespace that accepts the variadic portion as a vector:
(defn foo [a b & other] (some-impl/foo a b other))
-
Polylith simplifies testing by allowing access to implementing namespaces from the
testdirectory. Polylith restricts the code under thesrcdirectory to only access theinterfacenamespace. Thepolytool validates these restrictions when running the check, info, and test command. -
Because Polylith only allows the code under
srcto callinterfacecode, you can think of publicly declared implementation functions as protected (as in Java). Because these "protected" functions are technically public, you can test and debug them more easily. For example, when stopping at a breakpoint to evaluate a "protected" function, you don’t need to use the special syntax you would need to access a private function.
-
Polylith will always recognize
interfaceandifcas interface namespace names. By default, it will generate code usinginterfaceas the interface namespace name when you create a component. You can override this default via:interface-nsin./workspace.edn. Scenarios:-
You want to share code between Clojure and ClojureScript via
.cljcsource files. Sinceinterfaceis a reserved word in ClojureScript, it will cause problems. In this case, you can either:-
set
:interface-nstoifc,polywill useifcas the interface namespace name for generated code when you create a component -
or leave
interfaceas the default and override by specifyinginterface:ifcas an option to create component for components that will also run from ClojureScript.
-
-
You want to consume Clojure code from another language on the JVM, e.g., Kotlin, where
interfaceis a reserved word. You could set:interface-nsto anything that won’t conflict, for the sake of this example, let’s sayapi. Thepolytool would now useapifor the interface namespace name when it generates code when you create a component, but also recognizeinterfaceandifcas interface names.
-