Context
When I wrote the Haskell code for generating this blog, I tried to separate implementation detail from the business logic in a way that follows the Three Layer Cake. I like working with this concept because it keeps the IO layer at the edge, which makes my code easier to test (I did not write tests for this project of course). I’ve seen enough projects in other languages that abuse test mocks.
There are plenty of materials online that explain the Three Layer Cake, but I thought the interet could use more examples of how the concept could be applied to a real Haskell application. In this post I will walk through how I applied the Three Layer Cake to structure the static site generator for this blog.
Layer 1 - Implementation detail
In the source code, I treat the implementation detail as a collection of Haskell modules under the Interpreter namespace. These modules are made up of IO code and other operations that I thought could be separated from the business logic. In Three Layer Cake, this would be the layer 1 code.
module Kskkido.Blog.Interpreter.Generator where
-- ...
newtype Generator a = Generator
unwrap :: Reader.ReaderT Type.GeneratorConfig IO a
{
}deriving
Functor
( Applicative
, Monad
, MonadIO
, MonadReader Type.GeneratorConfig
,
)-- ...
getSiteConfig :: (Has.Has Type.GeneratorConfig r, Has.Has Pandoc.ReaderOptions r, Has.Has Pandoc.WriterOptions r, MonadIO m, MonadReader r m) => m Type.SiteConfig
= do
getSiteConfig <- getCurrentTime
time <- getPosts
posts <- getProfile
profile -- ...
module Kskkido.Blog.Interpreter.View where
-- ...
newtype View a = View
unwrap :: Reader.ReaderT Type.ViewConfig (Either Type.CoreException) a
{
}deriving
Functor
( Applicative
, Monad
, MonadReader Type.ViewConfig
,
)
instance Except.MonadError Type.CoreException View where
throwError :: Type.CoreException -> View a
= View . lift . Left
throwError catchError :: View a -> (Type.CoreException -> View a) -> View a
= do
catchError view handler <- ask
context let action = run context view
either handler pure action
instance Capability.Date.Date View where
= do
getCurrentTime <- ask
context pure $ context.now
Layer 3 - Business logic
The modules that are responsible for the business logic are made up of pure functions that perform the core work of the application. In the context of this application, the core work is to generate html files that make up the contents of the blog using lucid. This collection of modules make up layer 3 in Three Layer Cake.
module Kskkido.Blog.Templates.Pages.Home where
-- ...
render ::
Has.Has Type.Page r
( Has.Has Type.Locale r
, Has.Has Type.Router r
, Has.Has Type.SiteMap r
, Has.Has Type.LocalizedDictionary r
, Has.Has Type.Profile r
, Has.Has [Type.Post] r
, Has.Has [Type.Tag] r
, Capability.Date.Date m
, Except.MonadError Type.CoreException m
, MonadReader r m
, => Lucid.HtmlT m ()
) = do
render do
Root.render
Lucid.head_
Head.render
Lucid.body_
[ Lucid.classes_
[
]do
] do
Body.render
Home.render
Foot.render-- ...
Layer 2 - Bridging the two together
The code for the business logic (layer 3) does not directly communicate with the implementation details (layer 1). If it needs to call some function, it will do so through typeclasses, which, in the codebase, I call Capability. If it needs to reference some value in a Reader context, I will use data-has to make sure the business logic does not depend on a concrete implementation of the context. This abstraction of functionality and configuration makes the layer 2 code of the Three Layer Cake. You can think of them as interfaces that we use for dependency injection in OOP. The implementation code bundles its functions into concrete instances of the capability typeclasses (I like to think of the implementation code as an interpretation of the typeclasses and hence the Interpreter namespace) and create a Reader context that defines all the necessary data-has instances. For capabiltiies, I ended up using the mtl library out of familiarity, but I’m keen on trying out a library like polysemy if time allows.
module Kskkido.Blog.Capability.Date where
import qualified Data.Time.Clock as Time.Clock
class Date m where
getCurrentTime :: m Time.Clock.UTCTime
-- ...
module Kskkido.Blog.Interpreter.View where
-- ...
newtype View a = View
unwrap :: Reader.ReaderT Type.ViewConfig (Either Type.CoreException) a
{
}deriving
Functor
( Applicative
, Monad
, MonadReader Type.ViewConfig
,
)
-- ...
instance Capability.Date.Date View where
= do
getCurrentTime <- ask
context pure $ context.now
-- ...
module Kskkido.Blog.Interpreter.View.Type where
-- ...
data ViewConfig = ViewConfig
identifier :: Type.SiteItemIdentifier
{ now :: Time.Clock.UTCTime
, title :: Text
, route :: Text
, locale :: Type.Locale
, posts :: [Type.Post]
, tags :: [Type.Tag]
, profile :: Type.Profile
, router :: Type.Router
, siteMap :: Type.SiteMap
, localizedDictionary :: Type.LocalizedDictionary
,
}
instance Has.Has Type.Page ViewConfig where
= Type.Page
getter context = context.identifier
{ identifier = context.title
, title = context.route
, route = context.locale
, locale
}= context
modifier fn context = next.identifier
{ identifier = next.title
, title = next.route
, route = next.locale
, locale
}where next = fn $ Has.getter context
instance Has.Has Type.Profile ViewConfig where
= context.profile
getter context = context { profile = fn context.profile }
modifier fn context -- ...
Running the layers
The final piece of this implementation is to run the three layers together. I have two sets of layers in the codebase; one at the entrypoint of the whole application (app/Main.hs) and another within the application where I generate each page of the website. At the entrypoint of the application I’m calling the business logic (layer 3) for generating the website with a concrete implementation (layer 1) of a Reader context (layer 2). Likewise, I’m calling the business logic code for building a page (layer 3) with concrete implementations (layer 1) of its dependencies (layer 2).
module Main where
-- ...
main :: IO ()
= do
main IO.putStrLn "Starting"
<- Except.runExceptT do
result <- envFromSystem
env do
liftIO <- configFromEnv env
config <- Interpreter.Generator.toIO config do -- Layer 1
result <- Interpreter.Generator.getSiteConfig
siteConfig flip Reader.runReaderT siteConfig do
-- Layer 3
Generator.main case result of
Left exception -> fail $ displayException exception
Right artifacts -> do
.pagesFilePath) do
Extra.whenM (Directory.doesDirectoryExist env.pagesFilePath
Directory.removeDirectoryRecursive envTrue env.pagesFilePath
Directory.createDirectoryIfMissing do
$ \artifact -> do
for_ artifacts let filePath = env.pagesFilePath FilePath.</> dropWhile (== '/') (Text.unpack artifact.filePath)
= FilePath.takeDirectory filePath
directory True directory
Directory.createDirectoryIfMissing ByteString.Lazy.writeFile filePath artifact.file
IO.putStrLn filePath
do
let filePath = env.pagesFilePath FilePath.</> dropWhile (== '/') "_metadata.json"
= builtMetadataFromArtifacts artifacts
metadata ByteString.Lazy.writeFile filePath (Aeson.encode metadata)
case result of
Left exception -> do
IO.putStrLn "Failure"
IO.putStrLn exception
-> do
_ IO.putStrLn "Success"
-- ...
module Kskkido.Blog.Generator where
-- ...
main :: Except.MonadError Type.CoreException m => Reader.ReaderT Type.SiteConfig m [Type.Artifact]
= do
main <- Reader.ask
siteConfig <- fold <$> sequence
buildMap do
[ .defaultLocale ,) do
Reader.withReaderT (siteConfig locale :: Type.Locale <- asks Has.getter
-- ...
<$> sequence
fold -> do
[ for (Map.toList buildMap) \(_, item) do
Reader.mapReaderT Except.liftEither case item.content of
Type.StaticContent{..} -> do
pure $ Type.Artifact
= item.filePath
{ filePath = file
, file = item.route
, route
}Type.PageContent{..} -> do
let config = Type.ViewConfig
= siteMap
{ siteMap = item.identifier
, identifier = posts
, posts = tags
, tags = profile
, profile = locale
, locale = title
, title = item.route
, route = siteConfig.time
, now = siteConfig.router
, router = siteConfig.localizedDictionary
, localizedDictionary
}<- lift do
html do -- Layer 1 code
View.run config -- Layer 3 code
Lucid.renderBST template pure $ Type.Artifact
= item.filePath
{ filePath = html
, file = item.route
, route
} ]
Conclusion
I hope this post gave you some clarity on how to apply the Three Layer cake in a real Haskell application. Please feel free to reference the source code of the application on GitHub. I use the same concept to structure an implementation of realworld.io in Haskell, which is also available on Github.