In typical application development, when interacting with external APIs, databases, or other data sources, we often define functions that perform requests and handle their results or failures accordingly.
Simple Model Setup
Here’s a basic model that outlines the structure of our data and possible errors:
Defining API Functions
Let’s define functions that interact with an external API, handling common operations such as fetching todos, retrieving user details, and sending emails.
While this approach is straightforward and readable, it may not be the most efficient. Repeated API calls, especially when many todos share the same owner, can significantly increase network overhead and slow down your application.
Using the API Functions
While these functions are clear and easy to understand, their use may not be the most efficient. For example, notifying todo owners involves repeated API calls which can be optimized.
This implementation performs an API call for each todo to fetch the owner’s details and send an email. If multiple todos have the same owner, this results in redundant API calls.
Batching
Let’s assume that getUserById and sendEmail can be batched. This means that we can send multiple requests in a single HTTP call, reducing the number of API requests and improving performance.
Step-by-Step Guide to Batching
Declaring Requests: We’ll start by transforming our requests into structured data models. This involves detailing input parameters, expected outputs, and possible errors. Structuring requests this way not only helps in efficiently managing data but also in comparing different requests to understand if they refer to the same input parameters.
Declaring Resolvers: Resolvers are designed to handle multiple requests simultaneously. By leveraging the ability to compare requests (ensuring they refer to the same input parameters), resolvers can execute several requests in one go, maximizing the utility of batching.
Defining Queries: Finally, we’ll define queries that utilize these batch-resolvers to perform operations. This step ties together the structured requests and their corresponding resolvers into functional components of the application.
Declaring Requests
We’ll design a model using the concept of a Request that a data source might support:
A Request is a construct representing a request for a value of type Value, which might fail with an error of type Error.
Let’s start by defining a structured model for the types of requests our data sources can handle.
Each request is defined with a specific data structure that extends from a generic Request type, ensuring that each request carries its unique data requirements along with a specific error type.
By using tagged constructors like Request.tagged, we can easily instantiate request objects that are recognizable and manageable throughout the application.
Declaring Resolvers
After defining our requests, the next step is configuring how Effect resolves these requests using RequestResolver:
A RequestResolver requires an environment R and is capable of executing requests of type A.
In this section, we’ll create individual resolvers for each type of request. The granularity of your resolvers can vary, but typically, they are divided based on the batching capabilities of the corresponding API calls.
In this configuration:
GetTodosResolver handles the fetching of multiple Todo items. It’s set up as a standard resolver since we assume it cannot be batched.
GetUserByIdResolver and SendEmailResolver are configured as batched resolvers. This setup is based on the assumption that these requests can be processed in batches, enhancing performance and reducing the number of API calls.
Defining Queries
Now that we’ve set up our resolvers, we’re ready to tie all the pieces together to define our This step will enable us to perform data operations effectively within our application.
By using the Effect.request function, we integrate the resolvers with the request model effectively. This approach ensures that each query is optimally resolved using the appropriate resolver.
Although the code structure looks similar to earlier examples, employing resolvers significantly enhances efficiency by optimizing how requests are handled and reducing unnecessary API calls.
In the final setup, this program will execute only 3 queries to the APIs, regardless of the number of todos. This contrasts sharply with the traditional approach, which would potentially execute 1 + 2n queries, where n is the number of todos. This represents a significant improvement in efficiency, especially for applications with a high volume of data interactions.
Disabling Batching
Batching can be locally disabled using the Effect.withRequestBatching utility in the following way:
Resolvers with Context
In complex applications, resolvers often need access to shared services or configurations to handle requests effectively. However, maintaining the ability to batch requests while providing the necessary context can be challenging. Here, we’ll explore how to manage context in resolvers to ensure that batching capabilities are not compromised.
When creating request resolvers, it’s crucial to manage the context carefully. Providing too much context or providing varying services to resolvers can make them incompatible for batching. To prevent such issues, the context for the resolver used in Effect.request is explicitly set to never. This forces developers to clearly define how the context is accessed and used within resolvers.
Consider the following example where we set up an HTTP service that the resolvers can use to execute API calls:
We can see now that the type of GetTodosResolver is no longer a RequestResolver but instead it is:
which is an effect that access the HttpService and returns a composed resolver that has the minimal context ready to use.
Once we have such effect we can directly use it in our query definition:
We can see that the Effect correctly requires HttpService to be provided.
Alternatively you can create RequestResolvers as part of layers direcly accessing or closing over context from construction.
Example
This way is probably the best for most of the cases given that layers are the natural primitive where to wire services together.
Caching
While we have significantly optimized request batching, there’s another area that can enhance our application’s efficiency: caching. Without caching, even with optimized batch processing, the same requests could be executed multiple times, leading to unnecessary data fetching.
In the Effect library, caching is handled through built-in utilities that allow requests to be stored temporarily, preventing the need to re-fetch data that hasn’t changed. This feature is crucial for reducing the load on both the server and the network, especially in applications that make frequent similar requests.
Here’s how you can implement caching for the getUserById query:
Final Program
Assuming you’ve wired everything up correctly:
With this program, the getTodos operation retrieves the todos for each user. Then, the Effect.forEach function is used to notify the owner of each todo concurrently, without waiting for the notifications to complete.
The repeat function is applied to the entire chain of operations, and it ensures that the program repeats every 10 seconds using a fixed schedule. This means that the entire process, including fetching todos and sending notifications, will be executed repeatedly with a 10-second interval.
The program incorporates a caching mechanism, which prevents the same GetUserById operation from being executed more than once within a span of 1 minute. This default caching behavior helps optimize the program’s execution and reduces unnecessary requests to fetch user data.
Furthermore, the program is designed to send emails in batches, allowing for efficient processing and better utilization of resources.
Customizing Request Caching
In real-world applications, effective caching strategies can significantly improve performance by reducing redundant data fetching. The Effect library provides flexible caching mechanisms that can be tailored for specific parts of your application or applied globally.
There may be scenarios where different parts of your application have unique caching requirements—some might benefit from a localized cache, while others might need a global cache setup. Let’s explore how you can configure a custom cache to meet these varied needs.
Creating a Custom Cache
Here’s how you can create a custom cache and apply it to part of your application. This example demonstrates setting up a cache that repeats a task every 10 seconds, caching requests with specific parameters like capacity and TTL (time-to-live).
Direct Cache Application
You can also construct a cache using Request.makeCache and apply it directly to a specific program using Effect.withRequestCache. This method ensures that all requests originating from the specified program are managed through the custom cache, provided that caching is enabled.