Managing Services
On this page
In the context of programming, a service refers to a reusable component or functionality that can be used by different parts of an application. Services are designed to provide specific capabilities and can be shared across multiple modules or components.
Services often encapsulate common tasks or operations that are needed by different parts of an application. They can handle complex operations, interact with external systems or APIs, manage data, or perform other specialized tasks.
Services are typically designed to be modular and decoupled from the rest of the application. This allows them to be easily maintained, tested, and replaced without affecting the overall functionality of the application.
Overview
When diving into services and their integration in application development, it helps to start from the basic principles of function management and dependency handling without relying on advanced constructs. Imagine having to manually pass a service around to every function that needs it:
ts
const processData = (data: Data, databaseService: DatabaseService) => {// Operations using the database service}
ts
const processData = (data: Data, databaseService: DatabaseService) => {// Operations using the database service}
This approach becomes cumbersome and unmanageable as your application grows, with services needing to be passed through multiple layers of functions.
To streamline this, you might consider using an environment object that bundles various services:
ts
type Context = {databaseService: DatabaseServiceloggingService: LoggingService}const processData = (data: Data, context: Context) => {// Using multiple services from the context}
ts
type Context = {databaseService: DatabaseServiceloggingService: LoggingService}const processData = (data: Data, context: Context) => {// Using multiple services from the context}
However, this introduces a new complexity: you must ensure that the environment is correctly set up with all necessary services before it's used, which can lead to tightly coupled code and makes functional composition and testing more difficult.
The Effect library simplifies managing these dependencies by leveraging the type system.
Instead of manually passing services or environment objects around, Effect allows you to declare service dependencies directly in the function's type signature using the Requirements
parameter in the Effect<Success, Error, Requirements>
type.
- Dependency Declaration: You specify what services a function needs directly in its type, pushing the complexity of dependency management into the type system.
- Service Provision:
Effect.provideService
is used to make a service implementation available to the functions that need it. By providing services at the start, you ensure that all parts of your application have consistent access to the required services, thus maintaining a clean and decoupled architecture.
This method abstracts the manual handling of services and dependencies, allowing developers to focus on business logic while the compiler ensures that all dependencies are correctly managed. This approach not only simplifies code but also enhances its maintainability and scalability.
Let's explore how services are managed in Effect, step by step. You'll learn the essentials:
- Creating a Service: Define a service with its unique functionality and interface.
- Using the Service: Access and utilize the service within your application’s functions.
- Providing a Service Implementation: Supply an actual implementation of the service to fulfill the declared requirements.
Managing Services with Effects
Up to this point, our examples with the Effect framework have dealt with effects that operate independently of external services. This means the Requirements
parameter in our Effect<Success, Error, Requirements>
type signature has been set to never
, indicating no dependencies.
However, real-world applications often need effects that rely on specific services to function correctly. These services are managed and accessed through a construct known as Context
.
Context serves as a repository or container for all services an effect may require. It acts like a store that maintains these services, allowing various parts of your application to access and use them as needed.
The services stored within the Context are directly reflected in the Requirements
parameter of the Effect
type.
Each service within the Context is identified by a unique "tag," which is essentially a unique identifier for the service. When an effect needs to use a specific service, the service's tag is included in the Requirements
type parameter.
Creating a Service
Let's start by creating a service for generating random numbers.
To create a new service, you need two things:
- A unique identifier.
- A type describing the possible operations of the service.
Let's define our first service:
- We'll use the string
"MyRandomService"
as the unique identifier. - The service type will have a single operation called
next
that returns a random number.
ts
import {Effect ,Context } from "effect"classRandom extendsContext .Tag ("MyRandomService")<Random ,{ readonlynext :Effect .Effect <number> }>() {}
ts
import {Effect ,Context } from "effect"classRandom extendsContext .Tag ("MyRandomService")<Random ,{ readonlynext :Effect .Effect <number> }>() {}
The exported Random
value is known as a tag in Effect. It acts as a representation of the service and allows Effect to locate and use this service at runtime.
The service will be stored in a collection called Context
, which can be thought of as a Map
where the keys are tags and the values are services: Context = Map<Tag, Service>
.
You need to specify an identifier (in this case, the string "MyRandomService"
) to make the tag global. This ensures that two tags with the same identifier refer to the same instance.
Using a unique identifier is particularly useful in scenarios where live reloads can occur, as it helps preserve the instance across reloads. It ensures there is no duplication of instances (although it shouldn't happen, some bundlers and frameworks can behave unpredictably)
Summary
In the Effect, understanding services, tags, and context is essential for managing requirements and building modular applications.
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 service, functioning like a map with tags as keys and services as values. |
Using the Service
Now that we have our service tag defined, let's see how we can use it by building a simple program.
ts
import {Effect ,Context } from "effect"classRandom extendsContext .Tag ("MyRandomService")<Random ,{ readonlynext :Effect .Effect <number> }>() {}constprogram =Effect .gen (function* () {constrandom = yield*Random constrandomNumber = yield*random .next console .log (`random number: ${randomNumber }`)})
ts
import {Effect ,Context } from "effect"classRandom extendsContext .Tag ("MyRandomService")<Random ,{ readonlynext :Effect .Effect <number> }>() {}constprogram =Effect .gen (function* () {constrandom = yield*Random constrandomNumber = yield*random .next console .log (`random number: ${randomNumber }`)})
In the code above, we can observe that we are able to yield the Random
tag as if it were an Effect
itself.
This allows us to access the next
operation of the service.
It's worth noting that the type of the program
variable includes Random
in the Requirements
type parameter: Effect<void, never, Random>
.
This indicates that our program requires the Random
service to be provided in order to execute successfully.
If we attempt to execute the effect without providing the necessary service we will encounter a type-checking error:
ts
Argument of type 'Effect<void, never, Random>' is not assignable to parameter of type 'Effect<void, never, never>'. Type 'Random' is not assignable to type 'never'.2345Argument of type 'Effect<void, never, Random>' is not assignable to parameter of type 'Effect<void, never, never>'. Type 'Random' is not assignable to type 'never'.Effect .runSync () program
ts
Argument of type 'Effect<void, never, Random>' is not assignable to parameter of type 'Effect<void, never, never>'. Type 'Random' is not assignable to type 'never'.2345Argument of type 'Effect<void, never, Random>' is not assignable to parameter of type 'Effect<void, never, never>'. Type 'Random' is not assignable to type 'never'.Effect .runSync () program
To resolve this error and successfully execute the program, we need to provide an actual implementation of the Random
service.
In the next section, we will explore how to implement and provide the Random
service to our program, enabling us to run it successfully.
Providing a Service Implementation
In order to provide an actual implementation of the Random
service, we can utilize the Effect.provideService
function.
ts
construnnable =Effect .provideService (program ,Random , {next :Effect .sync (() =>Math .random ())})Effect .runPromise (runnable )/*Output:random number: 0.8241872233134417*/
ts
construnnable =Effect .provideService (program ,Random , {next :Effect .sync (() =>Math .random ())})Effect .runPromise (runnable )/*Output:random number: 0.8241872233134417*/
In the code snippet above, we call the program
that we defined earlier and provide it with an implementation of the Random
service.
We use the Effect.provideService
function to associate the Random
tag with its implementation, an object with a next
operation that generates a random number.
Notice that the Requirements
type parameter of the runnable
effect is now never
. This indicates that the effect no longer requires any service to be provided. With the implementation of the Random
service in place, we are able to run the program without any further requirements.
Extracting the Service Type
To retrieve the service type from a tag, use the Context.Tag.Service
utility type:
ts
import {Effect ,Context } from "effect"classRandom extendsContext .Tag ("MyRandomService")<Random ,{ readonlynext :Effect .Effect <number> }>() {}typeRandomShape =Context .Tag .Service <Random >/*This is equivalent to:type RandomShape = {readonly next: Effect.Effect<number>;}*/
ts
import {Effect ,Context } from "effect"classRandom extendsContext .Tag ("MyRandomService")<Random ,{ readonlynext :Effect .Effect <number> }>() {}typeRandomShape =Context .Tag .Service <Random >/*This is equivalent to:type RandomShape = {readonly next: Effect.Effect<number>;}*/
Using Multiple Services
When we require the usage of more than one service, the process remains similar to what we've learned in defining a service, repeated for each service needed. Let's examine an example where we need two services, namely Random
and Logger
:
ts
import {Effect ,Context } from "effect"// Create a tag for the 'Random' serviceclassRandom extendsContext .Tag ("MyRandomService")<Random ,{readonlynext :Effect .Effect <number>}>() {}// Create a tag for the 'Logger' serviceclassLogger extendsContext .Tag ("MyLoggerService")<Logger ,{readonlylog : (message : string) =>Effect .Effect <void>}>() {}constprogram =Effect .gen (function* () {// Acquire instances of the 'Random' and 'Logger' servicesconstrandom = yield*Random constlogger = yield*Logger // Generate a random number using the 'Random' serviceconstrandomNumber = yield*random .next // Log the random number using the 'Logger' servicereturn yield*logger .log (String (randomNumber ))})
ts
import {Effect ,Context } from "effect"// Create a tag for the 'Random' serviceclassRandom extendsContext .Tag ("MyRandomService")<Random ,{readonlynext :Effect .Effect <number>}>() {}// Create a tag for the 'Logger' serviceclassLogger extendsContext .Tag ("MyLoggerService")<Logger ,{readonlylog : (message : string) =>Effect .Effect <void>}>() {}constprogram =Effect .gen (function* () {// Acquire instances of the 'Random' and 'Logger' servicesconstrandom = yield*Random constlogger = yield*Logger // Generate a random number using the 'Random' serviceconstrandomNumber = yield*random .next // Log the random number using the 'Logger' servicereturn yield*logger .log (String (randomNumber ))})
The program
effect now has a Requirements
type parameter of Random | Logger
, indicating that it requires both the Random
and Logger
services to be provided.
To execute the program
, we need to provide implementations for both services:
ts
// Provide service implementations for 'Random' and 'Logger'construnnable1 =program .pipe (Effect .provideService (Random , {next :Effect .sync (() =>Math .random ())}),Effect .provideService (Logger , {log : (message ) =>Effect .sync (() =>console .log (message ))}))
ts
// Provide service implementations for 'Random' and 'Logger'construnnable1 =program .pipe (Effect .provideService (Random , {next :Effect .sync (() =>Math .random ())}),Effect .provideService (Logger , {log : (message ) =>Effect .sync (() =>console .log (message ))}))
Alternatively, instead of calling provideService
multiple times, we can combine the service implementations into a single Context
and then provide the entire context using the Effect.provide
function:
ts
// Combine service implementations into a single 'Context'constcontext =Context .empty ().pipe (Context .add (Random , {next :Effect .sync (() =>Math .random ()) }),Context .add (Logger , {log : (message ) =>Effect .sync (() =>console .log (message ))}))// Provide the entire context to the 'program'construnnable2 =Effect .provide (program ,context )
ts
// Combine service implementations into a single 'Context'constcontext =Context .empty ().pipe (Context .add (Random , {next :Effect .sync (() =>Math .random ()) }),Context .add (Logger , {log : (message ) =>Effect .sync (() =>console .log (message ))}))// Provide the entire context to the 'program'construnnable2 =Effect .provide (program ,context )
By providing the necessary implementations for each service, we ensure that the runnable effect can access and utilize both services when it is executed.
Optional Services
There are situations where we may want to access a service implementation only if it is available.
In such cases, we can use the Effect.serviceOption
function to handle this scenario.
The Effect.serviceOption
function returns an implementation that is available only if it is actually provided before executing this effect.
To represent this optionality it returns an Option of the implementation.
Let's take a look at an example that demonstrates the usage of optional services:
To determine what action to take, we can use the Option.isNone
function provided by the Option module. This function allows us to check if the service is available or not by returning true
when the service is not available.
ts
import {Effect ,Context ,Option } from "effect"classRandom extendsContext .Tag ("MyRandomService")<Random ,{ readonlynext :Effect .Effect <number> }>() {}constprogram =Effect .gen (function* () {constmaybeRandom = yield*Effect .serviceOption (Random )constrandomNumber =Option .isNone (maybeRandom )? // the service is not available, return a default value-1: // the service is availableyield*maybeRandom .value .next console .log (randomNumber )})
ts
import {Effect ,Context ,Option } from "effect"classRandom extendsContext .Tag ("MyRandomService")<Random ,{ readonlynext :Effect .Effect <number> }>() {}constprogram =Effect .gen (function* () {constmaybeRandom = yield*Effect .serviceOption (Random )constrandomNumber =Option .isNone (maybeRandom )? // the service is not available, return a default value-1: // the service is availableyield*maybeRandom .value .next console .log (randomNumber )})
In the code above, we can observe that the Requirements
type parameter of the program
effect is never
, even though we are working with a service. This allows us to access something from the context only if it is actually provided before executing this effect.
When we run the program
effect without providing the Random
service:
ts
Effect.runPromise(program).then(console.log)// Output: -1
ts
Effect.runPromise(program).then(console.log)// Output: -1
We see that the log message contains -1
, which is the default value we provided when the service was not available.
However, if we provide the Random
service implementation:
ts
Effect.runPromise(Effect.provideService(program, Random, {next: Effect.sync(() => Math.random())})).then(console.log)// Output: 0.9957979486841035
ts
Effect.runPromise(Effect.provideService(program, Random, {next: Effect.sync(() => Math.random())})).then(console.log)// Output: 0.9957979486841035
We can observe that the log message now contains a random number generated by the next
operation of the Random
service.