Dependency Validation of a Haskell Application
Abstract
In this post I’m presenting a DependencyChecker to validate module dependencies in Haskell applications that can easily be integrated in CI/CD pipelines. The solution is based on the Graphmod dependency visualization tool.
Introduction
Welcome to yet another sequel of Clean Architecture with Haskell and Polysemy.
In my last to posts (integration of Warp and Hal and configuration of a polysemy app) I was adding new features to my code base without caring much about one of the core rules of CleanArchitecture:
The overriding rule that makes this architecture work is The Dependency Rule. This rule says that source code dependencies can only point inwards. Nothing in an inner circle can know anything at all about something in an outer circle. In particular, the name of something declared in an outer circle must not be mentioned by the code in the an inner circle. That includes, functions, classes. variables, or any other named software entity.
At one point for example I noticed that a module in the InterfaceAdapters
package referenced code in the ExternalInterfaces
package further out.
This mishap was easy to fix by moving the module to the ExternalInterfaces
package , but I thought about ways to visualize module dependencies and to automatically verify that all dependencies comply to the dependency rule.
In this post I’ll share my findings.
Visualizing Module dependencies with graphmod
Whenever I think I had a brilliant idea, the Internet keeps telling me that someone else already had the same idea years ago…
So before starting to write my own Module Dependency Visualizer tool, I asked the Internet if others already had the same idea. And – not so surprisingly – I found Graphmod by Iavor S. Diatchki. It analyses cabal or stack based projects and outputs GraphViz DOT models.
After installing it with
cabal install graphmod
The following command generates a detailed view on the dependencies in the PolySemyCleanArchitecture project:
graphmod | dot -Tpdf > dependencies.pdf
Here is the output:
As required by the CleanArchitecture model all dependencies are directed inwards. No dependencies are going from inner layers to more outward layers.
Graphmod also provides additional flags to reduce clutter by pruning, to visualize the dependencies without package clustering, etc.
You’ll find a few examples in the graphmod wiki.
Automating CleanArchitecture dependency validation
Visually inspecting a code base in this way is great. But it still involves manual effort if we intend to re-evaluate this image after each code change.
Wouldn’t it be much more adequate to provide a fully automated dependency check to be include in each CI/CD run?
So in this section we are going to build such a tool.
How to define CleanArchitecture compliance?
According to the dependency rule only references from outer to inner layers are permitted.
Given the four packages of our PolysemyCleanArchitecture project:
-- | the list of source packages in descending order from outermost to innermost package in our CleanArchitecture project
cleanArchitecturePackages :: [Package]
= ["ExternalInterfaces", "InterfaceAdapters", "UseCases", "Domain"]
cleanArchitecturePackages
-- | this type represents the package structure of a module e.g. Data.Time.Calendar resides in package Date.Time
type Package = String
all permitted dependency pairs (fromModule, toModule)
can be computed with:
-- | for a given list of packages this function produces the set of all allowed dependency pairs between packages.
-- Allowed dependencies according to CleanArchitecture:
-- 1. imports within the same package
-- 2. imports from outer layers to inner layers
cleanArchitectureCompliantDeps :: [Package] -> [(Package, Package)]
= []
cleanArchitectureCompliantDeps [] @(p : ps) = zip (repeat p) lst ++ cleanArchitectureCompliantDeps ps cleanArchitectureCompliantDeps lst
cleanArchitectureCompliantDeps cleanArchitecturePackages
thus yields:
"ExternalInterfaces","ExternalInterfaces"),
[("ExternalInterfaces","InterfaceAdapters"),
("ExternalInterfaces","UseCases"),
("ExternalInterfaces","Domain"),
("InterfaceAdapters","InterfaceAdapters"),
("InterfaceAdapters","UseCases"),
("InterfaceAdapters","Domain"),
("UseCases","UseCases"),
("UseCases","Domain"),
("Domain","Domain")] (
The overall idea of the dependency check is to verify for all Haskell modules in our “src” folder that all their import statements are either contained in this list or are imports of some external libraries.
Getting a list of all import declarations of all .hs files
In this step I will reuse functions and types from Graphmod. Graphmod comes with a Graphmod.Utils
module that provides a function parseFile :: FilePath -> IO (ModName,[Import])
which parses a file into a representation of its import declaration section. ModName
and Import
are defined as follows:
data Import = Import { impMod :: ModName, impType :: ImpType } deriving Show
data ImpType = NormalImp | SourceImp deriving (Show,Eq,Ord)
data Qualifier = Hierarchy [String] | FromFile [String] deriving (Show)
type ModName = (Qualifier,String)
Given this handy parseFile
function we can collect all module import declaration under some folder dir
with the following code:
-- | this type represents the section of import declaration at the beginning of a Haskell module
type ModuleImportDeclarations = (ModName, [Import])
-- | scan all files under filepath 'dir' and return a list of all their import declarations.
allImportDeclarations :: FilePath -> IO [ModuleImportDeclarations]
= do
allImportDeclarations dir <- allFiles dir
files mapM parseFile files
-- | list all files in the given directory and recursively include all sub directories
allFiles :: FilePath -> IO [FilePath]
= do
allFiles dir <- listDirectory dir
files let qualifiedFiles = map (\f -> dir ++ "/" ++ f) files
concatMapM-> do
( \f <- doesDirectoryExist f
isFile if isFile
then allFiles f
else return [f]
) qualifiedFiles
Always fix things upstream
As of version 1.4.4 Graphmod can not be included as a library dependency via Cabal or Stack. This will be fixed in 1.4.5. I have provided an additional pull request that will allow to use the Graphmod.Utils
via a dependency declaration in your package.yaml
or cabal file. As long as version 1.4.5 is not available on Hackage we’ll have to use the respective version directly from Github by adding the following stanza to stack.yaml
:
extra-deps:
- git: https://github.com/yav/graphmod.git
commit: 79cc6502b48e577632d57b3a9b479436b0739726
Validating the module import declarations
Now that we have all ModuleImportDeclarations
collected in a list we must validate each of them.
We start with a function that validates the import declaration section of a single module as represented by a ModuleImportDeclarations
instance. In order to validate this section we have to provide two more items:
a list
allPackages
containing all packages under consideration (in our case thecleanArchitecturePackages
as defined above)a list of all compliant dependency pairings between elements of the
allPackages
list, in our case thecleanArchitectureCompliantDeps cleanArchitecturePackages
as defined above
-- | this function verifies a ModuleImportDeclarations instance
-- (that is all import declarations of a given Haskell module.)
-- If all imports comply to the rules Right () is returned.
-- If offending imports are found, they are returned via Left.
verifyImportDecl :: [Package] -> [(Package, Package)] -> ModuleImportDeclarations -> Either ModuleImportDeclarations ()
=
verifyImportDecl allPackages compliantDependencies (packageFrom, imports) let offending = filter (not . verify packageFrom) imports
in if null offending
then Right ()
else Left (packageFrom, offending)
where
-- | verify checks a single import declaration.
-- An import is compliant iff:
-- 1. it refers to some external package which not member of the 'packages' list or
-- 2. the package dependency is a member of the compliant dependencies between elements of the 'packages' list.
verify :: ModName -> Import -> Bool
=
verify pFrom imp `notElem` allPackages
importPackage imp || (modulePackage pFrom, importPackage imp) `elem` compliantDependencies
-- | this function returns the Package information from an Import definition
importPackage :: Import -> Package
= modulePackage (impMod imp)
importPackage imp
-- | this function returns the Package information from a ModName definition
modulePackage :: ModName -> Package
= intercalate "." (qualifierNodes q) modulePackage (q, _m)
As a next step we define a function that maps the function verifyImportDecl
over the complete list of all ModuleImportDeclarations
. This results in a List of Eithers. I’m using partitionEither
to transform the result into an
Either [(ModName, [Import])] ()
:
-- | verify the dependencies of a list of module import declarations. The results are collected into an 'Either [(ModName, [Import])] ()' which will be easier to handle in subsequent steps.
verifyAllDependencies :: [Package] -> [(Package, Package)] -> [ModuleImportDeclarations] -> Either [(ModName, [Import])] ()
= do
verifyAllDependencies allPackages compliantDependencies importslet results = map (verifyImportDecl allPackages compliantDependencies) imports
let (errs, _compliant) = partitionEithers results
if null errs
then Right ()
else Left errs
We can use the generic verifyAllDependencies
to create a specific verifyCleanArchitectureDependencies
function which uses cleanArchitecturePackages
and cleanArchitectureCompliantDeps
to define the dependency rules for our CleanArchitecture project:
-- | verify a list of ModuleImportDeclarations to comply to the clean architecture dependency rules.
verifyCleanArchitectureDependencies :: [ModuleImportDeclarations] -> Either [(ModName, [Import])] ()
=
verifyCleanArchitectureDependencies
verifyAllDependencies
cleanArchitecturePackages (cleanArchitectureCompliantDeps cleanArchitecturePackages)
Using the dependency checker in test cases
Using the dependency checker in test cases is quite straighforward. First load all import declaractions than validate them:
import Test.Hspec ( hspec, describe, it, shouldBe, Spec )
import DependencyChecker
ModName,
( ImpType(..),
Import(..),
fromHierarchy,
verifyCleanArchitectureDependencies,
allImportDeclarations )
main :: IO ()
= hspec spec
main
spec :: Spec
=
spec "The Dependency Checker" $ do
describe "ensures that all modules comply to the outside-in rule" $ do
it <- allImportDeclarations "src"
allImports
formatLeftAsErrMsg `shouldBe` Right () (verifyCleanArchitectureDependencies allImports)
As you can see from executing the tests with stack test
, the dependency checker does not find any issues in the codebase:
CleanArchitecture
The Dependency Checker
ensures that all modules comply to the outside-in rule
But if we add an offending dependency to some of the modules, say adding
import InterfaceAdapters.Config
to Domain.ReservationDomain
and to UseCases.ReservationUseCase
, we’ll get a failure with the following message:
Failures:
test/CleanArchitectureSpec.hs:22:75:
1) CleanArchitecture, The Dependency Checker, makes sure all modules comply to the outside-in rule
expected: Right ()
but got: Left [
"Domain.ReservationDomain imports InterfaceAdapters.Config",
"UseCases.ReservationUseCase imports InterfaceAdapters.Config"]
the rendering of the Error message is done by the helper function formatLeftAsErrMsg
:
-- | Right () is returned unchanged,
-- Left imports will be rendered as a human readable error message.
formatLeftAsErrMsg :: Either [ModuleImportDeclarations] () -> Either [String] ()
Right ()) = Right ()
formatLeftAsErrMsg (Left imports) = Left (map toString imports)
formatLeftAsErrMsg (where
toString :: ModuleImportDeclarations -> String
= ppModule modName ++ " imports " ++ intercalate ", " (map (ppModule . impMod) imports) toString (modName, imports)
Conclusion
Thanks to Graphmod rolling our own depency checker worked like a charm.
In this post I used my PolysemyCleanArchitecture project as an example. However the dependency checker is flexible enough to validate quite different dependency requirements as well.
For instance you could use it to limit the access to a database library to specific data-access modules in your codebase.
Adding such an automated dependency validation to a testsuite may help to maintain dependency constraints automatically with each execution of the CI/CD pipeline.