When working with a Polylith system, we want to keep everything as simple as possible and maximize our productivity. The Lego-like way of organising code into bricks, helps us with both of these goals.
One problem we normally have when developing software without using Polylith, is that the production environment and the development environment has a 1:1 relationship. This happens because we use the production codebase for development, so if we create a new service in production, it will automatically "turn up" in the development project.
In Polylith we avoid this problem by separating the development project from production. Thanks to components, we can create any project we want by putting the bricks we need into one place. This allows us to optimize the development environment for productivity while in production, we can focus on fulfilling non functional requirements like performance or up time.
Right now, our development project mirrors the command-line project:
Let's pretend we get performance problems in the user component and that we think distributing the load, by delegating to a new service, could solve the problem:
The production environment now looks good, but how about the development environment? The problem here is that it contains two components that share the same user interface. This will confuse both the classloader (if we start a REPL) and the IDE, because we now have two components using the same se.example.user namespace in the path, which is not a desirable situation.
The solution is to use profiles:
By leaving out any component that implements the user interface from the development project and combining it with one of the two possible profiles we get a complete development project. This allows us to work with the code from a single place, but still be able to mimic the various projects we have.
The default profile (if exists) is automatically merged into the development project, if no other profiles are selected. The name default is set by :default-profile-name in workspace.edn and can be changed, but here we will leave it as it is.
Now let's try to move from this design:
...to this:
First we need to decide how the command-line tool should communicate with user-service over the wire. After some searching, we found this slacker library that allows us to use remote procedure calls in a simple way.
Let's create a checklist that will take us there:
  1. 1.
    Create the user-api base.
  2. 2.
    Create the user-remote component.
  3. 3.
    Switch from user to user-remote in deps.edn for the command-line project.
  4. 4.
    Create the user-service project.
  5. 5.
    Build user-service.
Let's go through the list.
1. Create the user-api base:
  • Create the base.
  • Add the slacker library to the base.
  • Add paths to ./deps.edn.
  • Add slacker related libraries to ./deps.edn.
  • Implement the server for user-api:
Execute this statement:
poly create base name:user-api
Add the slacker library to bases/user-api/deps.edn:
:deps {slacker/slacker {:mvn/version "0.17.0"}}
Add user-api paths to ./deps.edn:
:aliases {:dev {:extra-paths [...
:test {:extra-paths [...
Add slacker related libraries to ./deps.edn:
:aliases {:dev
:test {:extra-paths [...
:extra-deps {slacker/slacker {:mvn/version "0.17.0"}
Create the api namespace:
├── bases
│ └── user-api
│ └── src
│ ├── se.example.user_api.api.clj
│ └── se.example.user_api.core.clj
...with this content:
(ns se.example.user-api.api
(:require [se.example.user.interface :as user]))
(defn hello-remote [name]
(user/hello (str name " - from the server")))
...and update the core namespace:
(ns se.example.user-api.core
(:require [se.example.user-api.api]
[slacker.server :as server])
(defn -main [& args]
(server/start-slacker-server [(the-ns 'se.example.user-api.api)] 2104)
(println "server started:"))
2. Create the user-remote component:
  • Create the component.
  • Add the slacker library to the component.
  • Remove the user paths from ./deps.edn.
  • Create the default and remote profiles.
  • Activate the remote profile in the IDE.
  • Activate the default profile in the REPL configuration.
  • Implement the component.
Create the component:
poly create component name:user-remote interface:user
Add the slacker library to components/user-remote/deps.edn:
:deps {slacker/slacker {:mvn/version "0.17.0"}}
Remove the user related paths from ./deps.edn:
:aliases {:dev {:extra-paths ["...
:test {:extra-paths ["components/user/test"
Add the default and remote profiles to ./deps.edn:
:aliases {...
:+default {:extra-paths ["components/user/src"
:+remote {:extra-paths ["components/user-remote/src"
Notice here that the profiles contain both src and test directories. This works as profiles are only used from the development project.
The next step is to activate the remote profile in our IDE:
Create the core namespace:
├── components
│ └── user-remote
│ └── src
│ ├── se.example.user_remote.core.clj
│ └── se.example.user_remote.interface.clj
...with this content:
(ns se.example.user.core
(:require [slacker.client :as client]))
(declare hello-remote)
(defn hello [name]
(let [connection (client/slackerc "localhost:2104")
_ (client/defn-remote connection se.example.user-api.api/hello-remote)]
(hello-remote name)))
...and update the interface namespace:
(ns se.example.user.interface
(:require [se.example.user.core :as core]))
(defn hello [name]
(core/hello name))
Edit the REPL configuration:
...and add the default profile to Aliases: "test,dev,build,+default"
The reason we have to do this, is because we removed the user component from the "main" paths in ./deps.edn and now we have to add it via a profile instead. We need the source code for the se.example.user.interface namespace, and we have two alternatives, the user or the user-remote component that both use this interface. The user component is a better default because it's simpler and only communicates via direct function calls without hitting the wire.
For the changes to take affect we now need to restart the REPL. Normally we don't have to do that, but when adding profiles it's necessary.
3. Switch from user to user-remote in deps.edn for the command-line project.
  • Replace user with user-remote for the command-line project.
  • Add the log4j library to deps.edn for command-line.
  • Create a command-line uberjar.
Update the configuration file for the command-line project:
├── projects
│ └── command-line
│ └── deps.edn
Replace user with user-remote, and add the log4j library (to get rid of warnings) in projects/command-line/deps.edn (it's okay to keep the poly/user name, because it's also the name of the interface that both user and user-remote share):
{:deps {poly/user {:local/root "../../components/user-remote"}
org.apache.logging.log4j/log4j-core {:mvn/version "2.13.3"}
org.apache.logging.log4j/log4j-slf4j-impl {:mvn/version "2.13.3"}}
Create an uberjar by executing:
clojure -A:deps -T:build uberjar :project command-line
4. Create the user-service project:
  • Create the project.
  • Update its deps.edn:
    • Add dependency to the user component.
    • Add dependency to the user-api base.
    • Add the aot and uberjar aliases.
  • Add the cl alias for the user-service.
Create the project:
poly create project name:user-service
Set the content of projects/user-service/deps.edn to this (or copy it from here):
{:deps {poly/user {:local/root "../../components/user"}
poly/user-api {:local/root "../../bases/user-api"}
org.clojure/clojure {:mvn/version "1.10.1"}
org.clojure/tools.deps.alpha {:mvn/version "0.12.1003"}
org.apache.logging.log4j/log4j-core {:mvn/version "2.13.3"}
org.apache.logging.log4j/log4j-slf4j-impl {:mvn/version "2.13.3"}}
:aliases {:test {:extra-paths []
:extra-deps {}}
:uberjar {:main se.example.user-api.core}}}
Add the user-s alias for the user-service and remove the :test keys in workspace.edn:
:projects {"development" {:alias "dev"}
"command-line" {:alias "cl"}
"user-service" {:alias "user-s"}}}
5. Build user-service.
Create an uberjar for the user-service:
clojure -A:deps -T:build uberjar :project user-service
Puhh, that should be it! Now let's test if it works.
Execute this from the workspace root in a separate terminal:
cd projects/user-service/target
java -jar user-service.jar
It should output (warnings can be ignored):
server started:
Now when we have a running service, we could test if we can call it from the REPL. We activated the remote profile in our IDE earlier, which made the user-remote component active. Note that this only instructs the IDE to treat user-remote as source code:
...but it doesn't load its source code into the REPL!
We can verify this by adding this code to development/src/dev/lisa.clj:
(ns dev.lisa
(:require [se.example.user.interface :as user]))
(user/hello "Lisa")
...and if we execute the hello function, we still get:
"Hello Lisa!!"
Remember that we set the REPL configuration to "dev,test,build,+default" which loads the user component into the REPL every time we start or restart the REPL. This is the recommended way of configuring the default REPL, by selecting the "simple" components that communicate with each other using direct function calls. Because of this, we should keep the "dev,test,build,+default" configuration as it is.
What we can do is to create another REPL configuration, e.g. "REPL prod", and set Aliases to "dev,test,build,+remote". This REPL will use the user-remote component and can be used to "emulate" a production like environment.
But let's continue with the REPL we already have and let's see if we can switch to user-remote without restarting the REPL. Open the core namespace of the user-remote component and select Tools > REPL > Load file in REPL. This will replace the user implementation with the user-remote component, which works because both live in the same se.example.user namespace, which is also their interface (user).
If we execute the hello function agan, we should get:
Hello Lisa - from the server!!
Now, let's continue with our example. Execute this from the other terminal (the one that we didn't start the server from):
cd ../../command-line/target
java -jar command-line.jar Lisa
Hello Lisa - from the server!!
Wow, that worked too! The complete code can also be found here.
Now execute the info command (+ inactivates all profiles, and makes the default profile visible):
cd ../../..
poly info +
...and compare it with the target design:
Looks like we got everything right!
The profile flags, st, follow the same pattern as for bricks and projects except that the last "Run the tests" flag is omitted.
This example was quite simple, but if our project is more complicated, we may want to manage state during development with a tool like Mount, Component, Integrant, or we could create our own helper functions that we put in the dev.lisa namespace, which can help us switch profiles by using a library like tools.namespace.
If we want to switch profile when running a command, we need to pass them in, e.g.:
poly info +remote
Now the remote profile is included in the development project and listed after active profiles.
It's possible to give more than one profile:
poly info +default +remote
The tool complains and doesn't like that we just included both user and user-remote in the development project!
The profiles can also contain libraries and paths to projects, but right now we have no such paths and therefore all profiles are marked with -- in the project section.
Now when we are finished with our example system, it could be interesting to see how many lines of code each brick and project consists of. This can be done by passing in :loc:
poly info :loc
Each project summarises the number of lines of code for each brick it contains. The loc column counts the number of lines of codes under the src directory, while (t) counts for the test directory.
Our projects are still quite small, but they will eventually reach 1000 lines of code, and when that happens we may want to change the thousand delimiter in ~/.polylith/config.edn which is set to , by default.
Let's run all the tests to see if everything works:
poly test :project
It worked!