Deconstruct the Monolith: Composable App Bootstrapping with Fractal Layer
How fractal-layer attacks the compilation bottleneck and initialization chaos of the monolithic App datatype by treating startup as a composable graph of resource-managed layers.
At Mercury we have a Haskell codebase that runs to the tens of thousands of modules, and at that size your instincts about what is expensive start to shift. Two things in particular graduate from “mildly annoying” to “actively threatens the project”: how long the thing takes to compile, and how it behaves at startup. More often than not, both of them trace back to the same place: the long stretch of main that wires the whole application together before it is allowed to do anything useful.
In a previous job I spent a lot of time in Scala, which is where I first ran into ZIO’s ZLayer. It was one of those ideas that reorganizes how you think about a problem: once you had wired an application up with it, the old way felt like assembling flat-pack furniture with the instructions withheld. So when I landed back in Haskell and caught myself writing the same four-hundred-line bracket pyramid I had cursed at in every other codebase, I started wondering why we didn’t have an equivalent. Eventually I stopped wondering and started writing one.
This is fractal-layer. It treats application startup as a composable graph of resource-managed layers rather than one long procedural main, and, almost as importantly, it gives you a way to see that graph on the occasions when startup misbehaves. The design owes an obvious debt to ZLayer1, which got there first and got it more right than I am likely to manage here; my contribution is mostly dragging the idea across into Haskell and leaning on the type system a little harder than the Scala version needs to. What follows is the problem it solves, how the library works, the part where it turns out to be an effect system wearing a fake mustache, and the places where it still falls short.
The monolithic App type
If you have ever built anything with Yesod, you have seen this. Somewhere near the root of the project, usually in a file called Types.hs or App.hs, there lives a foundation record that has slowly accreted every handle the application needs:
data App = App
{ dbConnection :: Pool Connection
, redisClient :: Redis.Connection
, logger :: LogEnv
, awsEnv :: AWS.Env
, stripeConfig :: StripeConfig
, emailService :: EmailService
, authService :: AuthService
, metricsClient :: Metrics.Client
, kafkaProducer :: Kafka.Producer
, s3Client :: S3.Client
-- ...and thirty other fields
}
It feels pragmatic when you start out, and the reasoning is hard to argue with: just put everything the app needs in one place, and reach for it wherever you need it. Then the project grows, and the bill for that convenience comes due in three installments.
Compilation cascades. Every module that needs any piece of application state has to import App, which means every one of those modules depends on the definition of App. Add a single field for a Slack integration that only the notifications code will ever touch, and you have changed a type the entire codebase depends on, so the entire codebase recompiles. On a large project a ten-second edit turns into a five-minute coffee break. I have lived this one for years; it is the single most common reason a Haskell monolith starts to feel actively hostile to work in.
Initialization hell. Your main turns into a procedural tower of nested brackets, each layer of the nest responsible for acquiring one resource and unwinding it again on the way out:
main :: IO ()
main = do
config <- loadConfig
withLogger config $ \logger -> do
logInfo logger "Starting database connection..."
withDatabase (dbConfig config) $ \db -> do
logInfo logger "Starting Redis..."
withRedis (redisConfig config) $ \redis -> do
logInfo logger "Starting AWS session..."
withAWS (awsConfig config) $ \aws -> do
-- ...ten more levels of nesting
withStripe (stripeConfig config) $ \stripe -> do
let app = App db redis logger aws stripe -- etc
runReaderT applicationLogic app
This is brittle in a few ways at once. The dependency order is implicit in the nesting, so the only record that “Redis needs the logger” is the fact that one bracket happens to sit inside another. If something throws halfway down, you are trusting that every bracket above it was written correctly enough to unwind whatever it had already acquired. And the whole thing is forced to run sequentially even when the resources have nothing to do with each other: Redis and Stripe could comfortably initialize in parallel, but the shape of the code rules that out.
False dependencies. This is the sneakiest cost, because nothing about it ever shows up as an error. Your auth module imports App purely to get at the database pool, but in doing so it now “knows about” the Kafka producer, the S3 client, and the email service too, none of which it has any business touching. You can no longer tell what a module actually depends on by reading its imports, and any refactor that might disturb App becomes a question you have to answer for the whole codebase at once.
Enter the ✨Layer✨
fractal-layer’s answer is to stop writing one giant initApp and instead describe each resource on its own, as a small self-contained value called a Layer. At its core a Layer is a type-safe recipe for building one thing:
newtype Layer m deps env = Layer
{ build :: LayerEnv m -> deps -> ResourceT m env }
Read the type out loud: given dependencies of type deps, I can build an env, and I will clean up after myself when the time comes. That cleanup runs through ResourceT, so finalizers fire in the correct LIFO order whether the program shuts down normally or something throws on the way up.
You build layers with two smart constructors that cover the large majority of cases. resource wraps an acquire/release pair so the finalizer is registered for you, and effect lifts a plain side-effecting computation that produces a value but has nothing to tear down:
-- A managed resource: acquire + release, both tracked.
dbLayer :: Layer IO DbConfig (Pool Connection)
dbLayer = resource createPool destroyAllResources
-- A side effect with no resource to clean up.
configLayer :: Layer IO () Config
configLayer = effect $ \_ -> loadConfig
Building a single layer is the easy part. The interesting question, and the reason any of this is worth a library, is what happens once you have a dozen of them and need them assembled in the right order.
Composing layers
Layer is a Category, an Arrow, a Profunctor, and a Monad. That is a slightly intimidating parade of type classes to hang on one newtype, but the practical upshot is friendly: you get to wire your layers together in whichever of those styles your team finds most readable, and they all produce the same result.
Arrow style
If you like being able to see the shape of the dependency graph directly on the page, arrow notation is hard to beat:
import Control.Arrow ((>>>), (&&&))
backend :: Layer IO AppConfig ServerHandle
backend = proc config -> do
-- db and cache have nothing to do with each other: start both, in parallel
(db, cache) <- (dbLayer &&& cacheLayer) -< (dbConfig config, cacheConfig config)
-- the server depends on both of these
server <- serverLayer -< (db, cache, serverConfig config)
returnA -< server
&&& is fan-out: it runs both branches, and because the two branches are independent they can be scheduled concurrently. >>> is ordinary left-to-right sequencing, for the cases where one thing must finish before the next begins. Between the two, the dependency graph ends up written out plainly in the syntax.
Applicative / monadic style
If arrow notation feels like more machinery than you want, the same wiring reads perfectly well as an ordinary do block. Because Layer is a Profunctor, you pull the relevant field out of your config with lmap:
backend :: Layer IO AppConfig ServerHandle
backend = do
db <- lmap dbConfig dbLayer
cache <- lmap cacheConfig cacheLayer
-- both are in scope; the server consumes them
lmap (\c -> (db, cache, serverConfig c)) serverLayer
All lmap :: (deps -> a) -> Layer m a env -> Layer m deps env does is narrow a layer’s input, which reads as “this layer only cares about one slice of the larger config.” Pick whichever of the two styles matches the complexity of the graph in front of you; they compile down to the same composition either way.
What you actually gain
- Modular compilation. Touching
Cache.hsno longer dragsDatabase.hsinto a recompile. Only the module that assembles the layers has to know about all the pieces at once. - Explicit dependencies.
serverLayer’s type signature states outright that it needs(DbHandle, CacheHandle, ServerConfig), so there is no guessing and no spelunking through source to find out what it secretly reaches for. - Resource safety for free.
&&&runs its branches concurrently, and the runner,withLayer, guarantees that every resource is released in reverse order, including the awkward case where initialization throws partway through and only some of the resources exist yet. - Parallelism as a property of the graph. Independent branches start together because nothing says they shouldn’t; the dependencies you wrote down are what force sequencing, and only where it is actually needed.
So it’s an effect system?
Kind of, yes. That parade of type classes is the giveaway. A Layer is a value that describes a computation, and withLayer is the thing that actually runs it. Say that back to yourself slowly and you will notice that what we have built is an effect system: a very small, very single-minded one, whose only effect is “acquire these resources, in this shape,” and whose interpreter exists for no other purpose than to run that and then step out of the way. There is no State effect, no Error effect, and no runtime concurrency primitive, on purpose, because none of those have any business running in the ten seconds your app spends coming up.
service, which we will get to properly in a moment, is where the comparison to type-class-driven dependency injection clicks into place. Writing service poolService and getting back a Pool Connection is doing the same job as a Has Database env constraint, or a MonadReader (Pool Connection): you ask for a value by its type, and the machinery hands it to you. The one difference is where that resolution happens. Type classes resolve through instance selection and follow you into every call site that mentions the constraint; fractal-layer resolves the same request exactly once, at boot, and hands you back a plain value that you then thread around by hand. Either way you are asking for a dependency by type. fractal-layer simply moves the question out of your runtime code and into startup, where it costs you nothing for the rest of the program’s life.
How far you take that framing is up to you, and the library is comfortable at either end. If all you want is resource initialization, treat it as exactly that: a tidy way to let disjoint parts of the application share a connection pool without having to coordinate, and to split the build graph so that a change in one corner stops dragging the rest into a recompile. If you want to lean in, the same Layer value is an effect system you can assemble your entire boot phase from. Most codebases land somewhere between the two, and nothing forces you to choose up front.
Decentralized initialization
Here is the part I think is most under-appreciated about this style: you never need a single god module that imports the entire world.
In the examples so far, Main.hs has imported all the layers and wired them together itself. That works, and for a small app it is perfectly fine. For a large one it is the wrong shape, because Main.hs then imports everything and becomes the very compilation bottleneck we set out to escape.
The alternative is to let each domain assemble its own dependencies, importing only its immediate neighbors and nothing further:
-- Database.hs: no dependencies
module Database (dbLayer) where
dbLayer :: Layer IO DbConfig DbHandle
dbLayer = resource initDb closeDb
-- UserService.hs: depends on Database
module UserService (userServiceLayer) where
import Database (dbLayer)
data UserService = UserService
{ getUser :: UserId -> IO User
, saveUser :: User -> IO ()
}
userServiceLayer :: Layer IO DbConfig UserService
userServiceLayer = do
db <- dbLayer
effect $ \_ -> pure UserService
{ getUser = queryUser db
, saveUser = insertUser db
}
-- AuthService.hs: depends on Database AND UserService
module AuthService (authServiceLayer) where
import Database (dbLayer)
import UserService (userServiceLayer)
data AuthService = AuthService
{ login :: Credentials -> IO (Maybe Token)
, verify :: Token -> IO (Maybe User)
}
authServiceLayer :: Layer IO DbConfig AuthService
authServiceLayer = do
db <- dbLayer
userService <- userServiceLayer
effect $ \_ -> pure AuthService
{ login = loginImpl db
, verify = verifyImpl userService
}
-- WebServer.hs: depends on AuthService, NOT Database
module WebServer (webServerLayer) where
import AuthService (authServiceLayer)
webServerLayer :: Layer IO (DbConfig, ServerConfig) Warp.Application
webServerLayer = do
auth <- lmap fst authServiceLayer
effect $ \(_, serverConfig) -> pure $ mkWebApp auth serverConfig
-- Main.hs: imports only WebServer
module Main where
import WebServer (webServerLayer)
main :: IO ()
main = do
config <- loadConfig
withLayer webServerLayer config $ \app ->
Warp.run 8080 app
Trace the chain upward: Database depends on nothing; UserService imports Database; AuthService imports both of them; WebServer imports only AuthService and has no notion that a database even exists; and Main imports only WebServer. No single module has to know about all the pieces. The compilation dependency graph now mirrors your actual domain dependency graph, which is the whole point of the exercise.
This is where the library gets its name. Initialization becomes fractal: each level composes the level just beneath it, and you can zoom in or out to whatever level of abstraction you care about at the moment without ever having to hold the whole picture in your head at once.
Services: the diamond problem
There is an obvious objection to the section I just finished. Both UserService and AuthService reference dbLayer, so if you compose them together, won’t you end up building the database pool twice?
Yes, if you compose them naively, you will. This is the classic diamond problem, and it is the one place in the whole design where “just compose the layers” can do the wrong thing without ever complaining:
Config
/ \
UserRepo AuthRepo
\ /
Application
A Service is a layer with a memory. You wrap an ordinary layer once with mkService :: Layer m deps env -> Service m deps env, and in return you get a value that can be dropped into the graph as many times as you like through service :: Service m deps env -> Layer m deps env. The first time the graph evaluates that service, the underlying layer runs and its result is cached; every reference after that hands back the same result instead of building a fresh one. That is precisely what the diamond wants: a single pool, created once and shared by every branch that asks for it.
-- Define the pool layer once, promote it to a service.
poolLayer :: Layer IO DbConfig (Pool Connection)
poolLayer = resource createPool destroyPool
poolService :: Service IO DbConfig (Pool Connection)
poolService = mkService poolLayer
userRepoLayer :: Layer IO (Pool Connection) UserRepo
userRepoLayer = effect $ \pool -> pure (UserRepo pool)
authRepoLayer :: Layer IO (Pool Connection) AuthRepo
authRepoLayer = effect $ \pool -> pure (AuthRepo pool)
-- `service` memoizes: the pool is built exactly once, then shared.
app :: Layer IO Config Application
app = proc config -> do
pool <- service poolService -< config
userRepo <- userRepoLayer -< pool
authRepo <- authRepoLayer -< pool
returnA -< Application userRepo authRepo
How the caching actually works
The implementation behind that has one consequence worth understanding before you lean on it. Services are memoized in a single shared cache that rides along on LayerEnv, and that cache is type-indexed: the key is the result type of the service, not the particular Service value you happened to wrap.
So the first time service poolService runs, it builds the pool and files the result away under the type Pool Connection. Every later service call whose result type is also Pool Connection gets that cached value handed back. Under the hood the store is an MVar wrapped around a type-keyed map, with a fast lock-free read path and a write-locked creation path, so that several threads asking for the same service at once block on a single initialization instead of racing to build it twice.
The consequence is worth stating flatly: you get one service per type per composition. If you do need two independent pools of the same underlying type, wrap each in its own newtype so the two land under different keys. Most of the time the single-instance behaviour is exactly what you were after, since there is usually only one database pool to go around, but it is the sort of rule that will bite you exactly once if nobody warned you it was there.
What about local / withReaderT?
withReaderT and local are what people tend to reach for instead, on the theory that ReaderT already does this. They solve a different problem, though, and the resemblance is only skin deep.
withReaderT :: (r' -> r) -> ReaderT r m a -> ReaderT r' m a lets you take a computation that expects one environment and run it against a larger one, by projecting the larger down to the smaller. That is a runtime adaptation of an environment that already exists. It says nothing whatsoever about how that environment came to exist in the first place. Somebody, somewhere, still has to write the nested brackets that produce it:
initApp :: IO AppEnv
initApp =
bracket initDb closeDb $ \db ->
bracket initRedis closeRedis $ \redis ->
bracket initAWS closeAWS $ \aws ->
pure $ AppEnv db redis aws
In other words, withReaderT is useful after you have already solved initialization. It does nothing to solve initialization.
It also leaves the monolithic type fully intact. Every module that wants to project out a slice still has to import AppEnv, so both the compilation cascade and the false dependencies survive untouched. And it cannot express parallel acquisition at all: ReaderT is sequential by construction, so getting any concurrency back means hand-rolling threads and MVars yourself, which is exactly the work we were trying to avoid.
Where local does shine is at the one job fractal-layer deliberately declines to do: temporary, request-scoped modifications to an environment that has already been built.
withRequestId :: RequestId -> ReaderT AppEnv IO a -> ReaderT AppEnv IO a
withRequestId reqId = local (\env -> env { currentRequestId = Just reqId })
So the two are tools for two different phases of the application’s life:
- Boot. Use fractal-layer to construct the environment, in the right order, with cleanup handled.
- Runtime. Hand the finished environment to
ReaderT, or whichever effect system you prefer, and uselocalfor the scoped tweaks it is good at.
They are complementary rather than competing. fractal-layer takes over the part of a ReaderT application that nobody enjoys writing, and leaves the part that works fine completely alone.
A bridge to your effect system
Having just called it an effect system, I should answer the obvious follow-up: is this trying to replace polysemy, or effectful, or fused-effects? No. Those libraries exist to run your application’s actual logic. Layer exists to run your application’s setup, hand you a finished value, and then get out of the way. Whatever runtime effect system you prefer is downstream of it, consuming the environment that fractal-layer assembled.
It helps to think of an application as living in two phases. Phase one is boot: the grubby business of opening sockets, allocating connection pools, reading environment variables, spawning background threads, and registering signal handlers. All of that is IO-heavy and resource-sensitive, and all of it is what fractal-layer is for. Phase two is runtime, where your effect system lives, be it plain ReaderT, polysemy, effectful, fused-effects, or anything else. fractal-layer has no opinion about which one you choose; it only produces the Env that phase two consumes.
A pattern I particularly like is to have fractal-layer build a record of functions, an approach I wrote about at length in an earlier post, and then hand that record to your runtime monad:
data Services = Services
{ getUser :: UserId -> IO User
, saveUser :: User -> IO ()
, sendEmail :: Email -> IO ()
, logMessage :: LogLevel -> Text -> IO ()
}
servicesLayer :: Layer IO Config Services
servicesLayer = proc config -> do
db <- dbLayer -< dbConfig config
email <- emailLayer -< emailConfig config
logger <- loggerLayer -< logConfig config
services <- effect $ \(db', email', logger') -> pure Services
{ getUser = \uid -> runDbQuery db' (selectUser uid)
, saveUser = \u -> runDbQuery db' (insertUser u)
, sendEmail = \e -> Email.send email' e
, logMessage = \lvl msg -> Logger.log logger' lvl msg
}
returnA -< services
main :: IO ()
main = do
config <- loadConfig
withLayer servicesLayer config $ \services ->
runReaderT applicationLogic services
The lifecycle questions, like “how do I set up the database and tear it down safely?”, live entirely in fractal-layer. The business-logic questions, like “how do I process a user registration?”, live in ReaderT. The separation is clean, and each side stays readable on its own terms.
Testing
This is where the approach repays you the most in day-to-day work. Because layers are ordinary values, putting a test implementation in place of a production one is nothing more than substituting one value for another. There is no mocking framework to learn and no runtime patching involved.
-- Production
prodDbLayer :: Layer IO DbConfig (Pool Connection)
prodDbLayer = resource createPool destroyAllResources
-- Test: in-memory, no bracket, no network
testDbLayer :: Layer IO () (IORef (Map UserId User))
testDbLayer = effect $ \() -> newIORef Map.empty
Because your application layer can be left polymorphic in the database type, the very same layer definition runs against a Pool Connection in production and an in-memory IORef (Map UserId User) in your tests:
type AppEnv db = (db, Logger, Cache)
appLayer :: Layer IO (db, LogConfig, CacheConfig) (AppEnv db)
appLayer = proc (db, logConfig, cacheConfig) -> do
logger <- loggerLayer -< logConfig
cache <- cacheLayer -< cacheConfig
returnA -< (db, logger, cache)
prodApp :: Layer IO Config (AppEnv (Pool Connection))
prodApp = proc config -> do
db <- prodDbLayer -< dbConfig config
env <- appLayer -< (db, logConfig config, cacheConfig config)
returnA -< env
testApp :: Layer IO (LogConfig, CacheConfig) (AppEnv (IORef (Map UserId User)))
testApp = proc (logConfig, cacheConfig) -> do
db <- testDbLayer -< ()
env <- appLayer -< (db, logConfig, cacheConfig)
returnA -< env
Notice that the test layer has a different type altogether, IORef where production has Pool. That substitution is flatly impossible with local, which can only swap one runtime value for another of the very same type. On top of that you get tests that are fast and deterministic, with no flaky network and no race against a live Redis, and you are free to mix and match per test: a live logger paired with an in-memory database and a mock cache, say.
Injecting failure
Swapping a whole layer for a fake is the obvious move. The subtler one, and the main reason I like having fractal-layer hand me a record of functions at the very end, is that you can reach in and make a single field misbehave while leaving everything else intact. Want to know how the app holds up when commits go flaky? Wrap saveUser so it throws one time in three. Want to see what a five-second email send does to your request latency budget? Wrap sendEmail with a delay. Everything else in the record stays production-true, so what you are testing is the system’s reaction to one specific, deliberate degradation rather than its reaction to a wholesale mock.
This is chaos engineering on the cheap. There are no library forks to maintain, no toxiproxy to stand up, and no yanking the ethernet cable out of the wall halfway through a test run. You reach into the record and bend one function. That is enough to flush out the class of bugs that only ever show up under partial failure, which, in my experience, is most of the bugs that end up waking somebody in the middle of the night.
Diagnostics: making startup visible
I called startup time existential at scale up at the top, and I meant it. When a service takes ninety seconds to come up and nobody can say which part is slow, what you need is to see the initialization graph rather than guess at it. fractal-layer ships diagnostics built for exactly that, sitting on top of a small interceptor system.
import Fractal.Layer.Diagnostics
main :: IO ()
main = do
(result, diagnostics) <- withLayerDiagnostics appLayer config $ \app ->
runApp app
-- ASCII tree of the whole boot, with timings
putStrLn $ renderLayerTree diagnostics
Which produces something like:
Application [2.45s]
├─ Config [0.01s]
├─ Parallel [2.32s]
│ ├─ Database [1.87s]
│ │ └─ ConnectionPool [1.85s] [SERVICE: pool-001]
│ └─ Cache [2.31s]
│ └─ Redis [2.30s]
├─ UserService [0.05s]
│ └─ Database [SHARED: pool-001]
└─ WebServer [0.12s]
├─ UserService [<cached>]
└─ Routes [0.11s]
Read that tree once and three things jump out: the database and cache came up in parallel; the connection pool was shared rather than duplicated when UserService asked for it again; and the cache, at 2.31s, is the thing holding up the whole boot. That last fact is the sort of thing that used to cost me an afternoon of traceEventIO and squinting to pin down.
Interceptors
Underneath that rendering sits a general interceptor mechanism: a set of callbacks fired at the lifecycle points of a layer, including acquire, release, service creation, service reuse, and the start and end of a composition. The diagnostics renderer is itself nothing more than one such interceptor, which means you can hang your own cross-cutting concerns off the same hooks without editing any layer code, whether that is logging, metrics, or OpenTelemetry spans.
loggingInterceptor :: LayerInterceptor IO
loggingInterceptor = LayerInterceptor
{ onResourceAcquire = \info -> logInfo $ "Acquiring: " <> resourceName info
, onResourceRelease = \info -> logInfo $ "Released: " <> resourceName info
, onServiceCreate = \info -> logInfo $ "Created service: " <> serviceName info
, onServiceReuse = \info -> logDebug $ "Reusing service: " <> serviceName info
, ...
}
You can stack several of them together with combineInterceptors, and when you want none of it at all, nullInterceptor compiles away to nothing. In our own measurements the diagnostics add under 5% overhead even when fully enabled, which is a rounding error next to the cost of actually opening a database connection.
What this looks like in production
I would be overselling this if I let it sound purely theoretical, so here is the concrete version. At Mercury, before patterns like this took hold, we lived with incremental builds that ran ten minutes on a good day and well past an hour on a bad one whenever someone touched a shared type, initialization failures that were close to impossible to debug after the fact, and a steady drip of test flakiness from resources that were not being cleaned up correctly. The decentralized-init model, together with the diagnostics, made all three tractable. We can see exactly where startup spends its time, confirm that the resources we meant to share are in fact shared, test individual services in isolation, and change one module without recompiling the world to find out whether it still builds.
There is a design choice underneath all of this that only gets more important as the codebase grows: fractal-layer is deliberately agnostic about almost everything above it. It does not impose an effect system, a logging story, or a particular shape for your domain types; it builds an environment and hands it off. At a couple of million lines of Haskell, and two to three times that today, the dependency graph is what decides whether the whole thing stays workable, and an opinionated framework that every module had to bend around would just become the next App to cascade through. Staying out of the way is the point: each module pulls in its direct neighbors and nothing else, so the graph keeps mirroring the domain however many modules you pile on top of it.
When you are maintaining a codebase of that size, how easily you can debug something often comes down to how small a slice of it you can stand up on its own. Because layers compose, you can load just the slice you care about into GHCi: withLayer dbLayer dbConfig $ \pool -> ... hands you a live, properly-managed connection pool and nothing else. You get to poke at one subsystem from a REPL, finalizers and all, instead of booting the entire application to reproduce a single thing.
Migrating, gradually
You do not have to rewrite your application to adopt any of this. The migration is incremental by design, and each individual step pays for itself before you take the next one.
Start with your most painful resource, which is usually the database, and lift just that one into a layer:
dbLayer :: Layer IO DbConfig DbHandle
dbLayer = resource initDb closeDb
main = withLayer dbLayer dbConfig $ \db ->
bracket initRedis closeRedis $ \redis ->
runApp (App db redis)
Then extract the next bracket, and the one after that. As soon as you have two independent layers, &&& will initialize them in parallel for free:
backend :: Layer IO (DbConfig, RedisConfig) (DbHandle, RedisHandle)
backend = dbLayer &&& redisLayer
Finally, start breaking up the App type itself. In place of one forty-field record, give each module the small, focused environment it actually uses, and let the composition at the top assemble them. This too can happen one module at a time, working inward from the leaves of the dependency tree. At no point are you forced into a big-bang rewrite.
Performance notes
The parallelism is automatic, and it is easy to underestimate how much it buys you. dbLayer &&& cacheLayer &&& queueLayer brings all three up at once when they are independent, so your boot time becomes max(initDb, initCache, initQueue) rather than the sum of the three. For resources whose initialization involves network round-trips or slow handshakes, that gap is the difference between a deploy that feels snappy and one that feels sluggish.
The per-layer overhead, meanwhile, is negligible: a little ResourceT bookkeeping, one MVar lookup per memoized service, and arrow composition that inlines away to nothing. If your initialization is measured in seconds, the machinery underneath it is measured in microseconds.
And then there is compilation, which was the point of the whole thing. Because each module imports only its direct dependencies instead of a global App, the recompilation graph finally follows the shape of your domain. On a large project that change alone can turn incremental builds from minutes back into seconds, and for us it has been the highest-leverage win by a wide margin.
Limitations
None of this is free, so here are the things worth going in with your eyes open about:
- There is a learning curve. The
Functor,Applicative, andMonadinstances will feel familiar to most Haskellers; the arrow combinators (&&&,>>>, andprocnotation) usually less so. You can stay entirely in applicative and monadic style if arrows feel foreign, and reach for arrow notation only later, once a dependency graph gets dense enough that drawing it out pays for itself. - Type errors can get gnarly. Compose enough layers and you will eventually run into something like
Couldn't match type '(A, B)' with '(C, D)' in the composition of .... The standard antidotes are typed holes, explicit type annotations on the intermediate layers, and pulling large compositions apart into smaller named functions. This is the tax you pay for wiring that the compiler actually checks. - It stays well out of DI-container territory. There is no automatic discovery of dependencies, no reflection, and no magical wiring happening behind your back. If that is what you are after,
registryorcapabilitywill suit you better. fractal-layer is deliberately lower-level and explicit about every connection. - One service per type. As covered above, the type-keyed cache means two distinct services cannot share a result type unless you disambiguate them with a
newtype. - Initialization is still
IO. Acquisition failures surface as ordinaryIOexceptions rather than typed errors. If you want typed error handling during boot specifically, you will need to layer anExceptT, or something like it, inside your layer definitions yourself.
Related work
fractal-layer has several neighbors in this space, each making a different tradeoff:
registrykeeps a type-indexed map of constructors and infers the wiring for you automatically. That means less ceremony up front, at the cost of dependencies being inferred rather than spelled out, error messages that can get cryptic, and less direct control over initialization order.capabilityexpresses capabilities as type classes withHasField-style access. It is excellent for keeping code polymorphic and it sits well alongside an effect system, but it does not address initialization at all; you are still on the hook for building theServicesrecord yourself.riogives you aRIOmonad that carries an environment, with batteries included for logging and similar concerns. It is simple and a fine way to get moving, but it still leaves the initialization for you to write, and it does nothing about the compilation cascade.- Plain
ReaderTwith smart constructors adds no dependencies and is about as explicit as it gets. It also gives you no automatic cleanup, no parallel acquisition, no composition story, and leaves the cascade exactly where it was.
fractal-layer sits at a particular point on that spectrum: explicit, composable, resource-safe, and instrumented. Its whole aim is to make the least enjoyable part of a large Haskell application both bearable to write and possible to inspect, while leaving the magic to the libraries that want to provide it.
When to reach for it
Reach for it when your application is growing and a monolithic App type has started to make compiles painful; when you have enough resources around that their ordering and cleanup have become a concern in their own right; when you want to be able to swap implementations freely for testing; when startup time is something you actually watch; or simply when you want your dependency graph to be legible in the code rather than reconstructed in your head.
Skip it when your application is small and has only a handful of resources, where plain ReaderT will serve you perfectly well, or when your team would rather have automatic dependency resolution than spell the wiring out by hand.
Further reading
- fractal-layer on GitHub
- Diagnostics integration docs
- The ReaderT design pattern
- Records of Functions (companion post on this site)
Acknowledgments
fractal-layer was built at Mercury to solve initialization problems in large-scale Haskell codebases. The design follows ZIO’s ZLayer, which pioneered treating dependency provisioning as a first-class, composable concern. I’m grateful to the ZIO team for demonstrating that initialization was worth treating as a design problem in its own right. Thanks to the Haskell community for the arrow and ResourceT foundations that make composable resource management expressible in our type system at all.
Footnotes
-
ZIO’s
ZLayeris the clearest prior art for treating dependency provisioning as a first-class, composable value. If you’ve used it, a lot of what follows will feel familiar, and that’s intentional. Full credit to the ZIO team for showing that initialization deserves to be designed for, not treated as a chore. ↩