Decouple the Program from its Implementation with ZIO modules
Requirements: A basic understanding of ZIO or another ‘Side-Effect Library’.
On the way you will get in contact with mill, yaml, circe-yaml, HOCON and PureConfig - but no worries you do not need to know them.
I am just a user / learner of ZIO and Functional Programming in general.
When I first learnt about ZIO / Effect Systems, I read somewhere that with this approach it is possible to separate the Program from its Implementations. Or in other words separate the Domain Logic from the Infrastructure.
Recently I wanted to compare two technologies to:
* read some Configuration Files
* create Case Classes from them
* render the file content from the Case Classes.
After seeing the similarities between the implementations, I wanted to give it a try.
The result can be found here: zio-comps-module and you can read about it here in this Blog ;).
What we want
Evaluate different possibilities to handle Components that are defined by humans.
This diagram shows our Scenario:
The Core
Let’s describe now our Implementation agnostic Core Module.
Domain Model
We have different Components that can be described with configuration files. Each type has its own Component. For example we have DBConnection- or a MessageBundle Component.
There are only dependencies to the standard Scala library.
Service Interface
Our Service contains two functionalities:
- You can load a Component from a reference to a Component (CompRef). A CompRef can be resolved to a file on the local file system.
- You can render a String of a Component (which then could be for example persisted in a file).
General Program (CompApp)
The general program flow is used by both implementation:
The program loads some Components and renders them. The dbConnection can be retrieved by the dbLookup.dbConRef.
This is a typical flow with ZIO. We describe the happy path in a for-comprehension and in the end we yield 0 (success on the console).
If there are any exception along the way, we want to catchAll, log the problem and return 1 (which indicates an error on the console)
The Service and the Program have only dependencies to ZIO and the standard Scala library.
The Implementations
First stack:
- YAML as file format
- circe-yaml / circe for decoding and encoding
Second stack:
- HOCON as file format
- PureConfig for decoding / encoding
The HOCON Implementation
So what is needed now to add an actual implementation?
We add a mill Module hocon with its dependency to the core Module and PureConfig (see here the whole build.sc).
loadConfig
As the loadConf
function is the complex one, due to its generic nature, I focus on this for now.
PureConfig loads the configuration from the Resource path:
ConfigSource.resources(s”${ref.url}.conf”)
And translates this ConfigSource to the Component via loadOrThrow[T]
. This function expects an implicit ConfigReader and ClassTag. We add them as Context Bounds (see FAQ/context-bounds for more infos).
This also needed some help from the community, see Generic Function that returns a specific object.
As this function is synchronous and may throw an Exception, we wrap them with ZIO’s Task.effect
. See ZIO documentation for more information.
The return type RIO[Console, T]
indicates three things:
T
: As the result we yield the created Component.Console
: The environment must be aConsole
(because we want to log some information to the Console).RIO
: In the Error case, the Error Type will be Throwable. BecauseRIO[R, T]
is a shortcut forZIO[R, Throwable, T]
. See the ZIO type-aliases.
The service implementation
We extend the Components Module, like:
As you can see the signature of the load
function is different to the one of the loadConf
function.
My first thought was, that as the requiredConfigReader
is in scope through import pureconfig.generic.auto._
this should work:
loadConf[T](ref)
But sadly it didn’t:
Error:(37, 18) could not find implicit value for evidence parameter of type pureconfig.ConfigReader[T]
loadConf[T](ref)
So its implementation took me some time to figure out.
First I call loadConf
with the Component type, and then map it to the required type T:
loadConf[Component](ref).map { case c: T => c }
This is ugly, as it is more or less a type cast with the according warnings when compiling:
[warn] /../hocon/HoconComps.scala:37:46: abstract type pattern T is unchecked since it is eliminated by erasure
[warn] loadConf[Component](ref).map { case c: T => c }
[warn] ^
Finally adding the ClassTag ContextBound got rid of the warnings:
trait Service[R] {
def load[T <: Component: ClassTag](ref: CompRef): RIO[R, T]
}
Hocon App
Ok - what is now needed to actually use this implementation?
Cool, right? These simple steps are everything:
- Extend our general CompApp.
- Implement the run function from the zio.App.
- Provide our implementation together with the Console to the general Program, that we defined above.
Running the HoconApp prints to the Console.
For a loaded Component:
Component:
DbLookup(postcodeLookup,LocalRef(odsDb),
SELECT f_postcode FROM t_places
WHERE f_name == ?
,Map(name -> Schwändi))
For a rendered Component
Component File postcodeLookup.conf :
{
“db-con-ref” : {
“name” : “odsDb”,
“type” : “local-ref”
},
“name” : “postcodeLookup”,
“params” : {
“name” : “Schwändi”
},
“statement” : “\nSELECT f_postcode FROM t_places\n WHERE f_name == ?\n”,
“type” : “db-lookup”
}
The YAML Implementation
As this is so close, I don’t repeat it here; check the code if you are interested: zio-comps-module/yaml
MyApp
If we want to have just a single App, we create a new Mill module in the build.sc, like
object app extends MyModule {
override def moduleDeps = Seq(yaml, hocon)
}
And add another CompApp:
That’s it. Running them for example with Mill:
# run the YAML implementation:
mill app.run YAML# run the HOCON implementation:
mill app.run
Conclusion
I could solve my main problem — see here for reference : Delegate to a more specific Context Bound.
But other than that, this seems to be a great way to separate the Program from its Implementations.
There are already ideas for the next Blogs 😏, like:
- How could this be done dynamically, say we define the Implementation in a config-file. Update: done — see How to dynamically inject …
- How to provide general tests to test any Implementation. Update: done — see How you can use the same Test…
- Provide a Mock so you can easily use the Module without having any Implementation.
References
The Project to this Blog: zio-comps-module
ZIO: https://zio.dev
Mill: http://www.lihaoyi.com/mill/
PureConfig: https://pureconfig.github.io
circe-yaml: https://github.com/circe/circe-yaml
YAML: https://yaml.org
HOCON: https://github.com/lightbend/config/blob/master/HOCON.md
Thanks to the reviewer Peti Koch.
Let me know if you have questions or stuff that can be done better!