In the Managing Services page, you learned how to create effects which depend on some service to be provided in order to execute, as well as how to provide that service to an effect.
However, what if we have a service within our effect program that has dependencies on other services in order to be built? We want to avoid leaking these implementation details into the service interface.
To represent the “dependency graph” of our program and manage these dependencies more effectively, we can utilize a powerful abstraction called “Layer”.
Layers act as constructors for creating services, allowing us to manage dependencies during construction rather than at the service level. This approach helps to keep our service interfaces clean and focused.
Let’s review some key concepts before diving into the details:
Concept
Description
service
A reusable component providing specific functionality, used across different parts of an application.
tag
A unique identifier representing a service, allowing Effect to locate and use it.
context
A collection storing services, functioning like a map with tags as keys and services as values.
layer
An abstraction for constructing services, managing dependencies during construction rather than at the service level.
Designing the Dependency Graph
Let’s imagine that we are building a web application. We could imagine that the dependency graph for an application where we need to manage configuration, logging, and database access might look something like this:
The Config service provides application configuration.
The Logger service depends on the Config service.
The Database service depends on both the Config and Logger services.
Our goal is to build the Database service along with its direct and indirect dependencies. This means we need to ensure that the Config service is available for both Logger and Database, and then provide these dependencies to the Database service.
Avoiding Requirement Leakage
When constructing the Database service, it’s important to avoid exposing the dependencies on Config and Logger within the Database interface.
You might be tempted to define the Database service as follows:
Example (Leaking Dependencies in the Service Interface)
Here, the query function of the Database service requires both Config and Logger. This design leaks implementation details, making the Database service aware of its dependencies, which complicates testing and makes it difficult to mock.
To demonstrate the problem, let’s create a test instance of the Database service:
Example (Creating a Test Instance with Leaked Dependencies)
Because the Database service interface directly includes dependencies on Config and Logger, it forces any test setup to include these services, even if they’re irrelevant to the test. This adds unnecessary complexity and makes it difficult to write simple, isolated unit tests.
Instead of directly tying dependencies to the Database service interface, dependencies should be managed at the construction phase.
We can use layers to properly construct the Database service and manage its dependencies without leaking details into the interface.
Creating Layers
The Layer type is structured as follows:
A Layer represents a blueprint for constructing a RequirementsOut (the service). It requires a RequirementsIn (dependencies) as input and may result in an error of type Error during the construction process.
Parameter
Description
RequirementsOut
The service or resource to be created.
Error
The type of error that might occur during the construction of the service.
RequirementsIn
The dependencies required to construct the service.
By using layers, you can better organize your services, ensuring that their dependencies are clearly defined and separated from their implementation details.
For simplicity, let’s assume that we won’t encounter any errors during the value construction (meaning Error = never).
Now, let’s determine how many layers we need to implement our dependency graph:
Layer
Dependencies
Type
ConfigLive
The Config service does not depend on any other services
Layer<Config>
LoggerLive
The Logger service depends on the Config service
Layer<Logger, never, Config>
DatabaseLive
The Database service depends on Config and Logger
Layer<Database, never, Config | Logger>
When a service has multiple dependencies, they are represented as a union type. In our case, the Database service depends on both the Config and Logger services. Therefore, the type for the DatabaseLive layer will be:
Config
The Config service does not depend on any other services, so ConfigLive will be the simplest layer to implement. Just like in the Managing Services page, we must create a tag for the service. And because the service has no dependencies, we can create the layer directly using the Layer.succeed constructor:
Looking at the type of ConfigLive we can observe:
RequirementsOut is Config, indicating that constructing the layer will produce a Config service
Error is never, indicating that layer construction cannot fail
RequirementsIn is never, indicating that the layer has no dependencies
Note that, to construct ConfigLive, we used the Config.of
constructor. However, this is merely a helper to ensure correct type inference
for the implementation. It’s possible to skip this helper and construct the
implementation directly as a simple object:
Logger
Now we can move on to the implementation of the Logger service, which depends on the Config service to retrieve some configuration.
Just like we did in the Managing Services page, we can yield the Config tag to “extract” the service from the context.
Given that using the Config tag is an effectful operation, we use Layer.effect to create a layer from the resulting effect.
Looking at the type of LoggerLive:
we can observe that:
RequirementsOut is Logger
Error is never, indicating that layer construction cannot fail
RequirementsIn is Config, indicating that the layer has a requirement
Database
Finally, we can use our Config and Logger services to implement the Database service.
Looking at the type of DatabaseLive:
we can observe that the RequirementsIn type is Config | Logger, i.e., the Database service requires both Config and Logger services.
Combining Layers
Layers can be combined in two primary ways: merging and composing.
Merging Layers
Layers can be combined through merging using the Layer.merge function:
When we merge two layers, the resulting layer:
requires all the services that both of them require ("In1" | "In2").
produces all services that both of them produce ("Out1" | "Out2").
For example, in our web application above, we can merge our ConfigLive and LoggerLive layers into a single AppConfigLive layer, which retains the requirements of both layers (never | Config = Config) and the outputs of both layers (Config | Logger):
Composing Layers
Layers can be composed using the Layer.provide function:
Sequential composition of layers implies that the output of one layer is supplied as the input for the inner layer,
resulting in a single layer with the requirements of the outer layer and the output of the inner.
Now we can compose the AppConfigLive layer with the DatabaseLive layer:
We obtained a MainLive layer that produces the Database service:
This layer is the fully resolved layer for our application.
Merging and Composing Layers
Let’s say we want our MainLive layer to return both the Config and Database services. We can achieve this with Layer.provideMerge:
Providing a Layer to an Effect
Now that we have assembled the fully resolved MainLive for our application,
we can provide it to our program to satisfy the program’s requirements using Effect.provide: