Create a new file at ecom/product/handlers.ts and add the following code:
import { Effect } from "effect";
import { productRpcGroup } from "./procedures.js";import { ProductsService } from "./service.js";
export const productHandlersLayer = productRpcGroup.toLayer( Effect.gen(function* () { const products = yield* ProductsService; return productRpcGroup.of({ GetProducts: () => products.getProducts(), }); }),);A handler is the actual piece of code that executes your business logic whenever a specific procedure is invoked.
In Effect RPC, handlers for a group are defined using the group’s .toLayer() method. You pass it an Effect that, after acquiring any services it needs (here, ProductsService), returns an object mapping each procedure tag to its handler implementation.
To build that object, we call productRpcGroup.of({...}). The .of() helper is a type-safety guard: TypeScript ensures that every procedure declared in the group has a matching handler entry with the correct signature. If you forget one, you’ll get a compile error.
Understanding the Handler Function
Each handler function receives two arguments:
payload: The validated input that the procedure was invoked with. This is fully typed against the procedure’s payload schema. SinceGetProductsdoes not declare a payload, the argument isvoid, so we ignore it and define our handler as() => ....options: An object containing per-request metadata:headers: The headers the client sent with this request.requestId: A unique identifier for this request.client: AServerClientthat you can use to make nested RPC calls back to the originating client.rpc: A reference to the RPC definition itself.
Our GetProducts handler doesn’t need either of these. You’ll see the payload argument in action in the next chapter, where we define a procedure called GetProductById that takes a product ID as input.
Implementing the Business Logic
Let’s create the ProductsService to handle the actual data fetching from the DummyJSON API.
Create a new file at ecom/product/service.ts and add the following code:
import { Effect, Layer, Context, Config, ConfigProvider } from "effect";import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse,} from "effect/unstable/http";
import { ProductsFetchError, ProductsInvalidResponseError } from "./errors.js";import { productListSchema, type ProductList } from "./schemas.js";
export class ProductsService extends Context.Service<ProductsService>()( "app/ProductsService", { make: Effect.gen(function* () { const baseUrl = yield* Config.string("BASE_URL").pipe( Config.nested("DUMMY_JSON"), );
const client = (yield* HttpClient.HttpClient).pipe( HttpClient.mapRequest(HttpClientRequest.prependUrl(baseUrl)), HttpClient.filterStatusOk, );
// ==================================== // Fetches the full list of products // ====================================
function getProducts(): Effect.Effect< ProductList, ProductsFetchError | ProductsInvalidResponseError > { return client.get("/products").pipe( Effect.flatMap(HttpClientResponse.schemaBodyJson(productListSchema)), Effect.catchTag("SchemaError", (error) => Effect.fail( new ProductsInvalidResponseError({ cause: error.message }), ), ), Effect.catchTag("HttpClientError", (error) => Effect.fail(new ProductsFetchError({ cause: error.message })), ), ); }
return { getProducts }; }), },) { static Live = Layer.effect(this, this.make).pipe( Layer.provide(FetchHttpClient.layer), Layer.provide(ConfigProvider.layer(ConfigProvider.fromDotEnv())), );}There’s a lot going on here. Let’s break it down.
Reading the base URL from the environment
Inside ProductsService, we first define a baseUrl using the following code:
const baseUrl = yield * Config.string("BASE_URL").pipe(Config.nested("DUMMY_JSON"));Config.string("BASE_URL") describes what config value to read. Config.nested("DUMMY_JSON") adds a prefix, so Effect looks up DUMMY_JSON_BASE_URL. But where does it read this value from?
Every Effect program comes with a built-in ConfigProvider that reads from process.env. Because in our case the environment variable will be inside a .env file (which we’ll create next), we replace the built-in provider with ConfigProvider.fromDotEnv(), which reads the .env file from disk and parses it. We install it using Config.layer(ConfigProvider.fromDotEnv()), which tells Effect to use this provider instead of the built-in one.
Now when the service runs yield* Config.string("BASE_URL").pipe(Config.nested("DUMMY_JSON")), Effect looks up DUMMY_JSON_BASE_URL in the .env file instead of process.env.
Fetching products from DummyJSON
Inside the getProducts() function, we use the pre-configured client to perform a GET request to the /products endpoint. The result is then piped into HttpClientResponse.schemaBodyJson(productListSchema), which automatically parses the JSON response and validates it against our schema. If the validation fails, we catch the SchemaError and wrap it in a ProductsInvalidResponseError. Similarly, if the request itself fails, we catch the HttpClientError and return a ProductsFetchError.
Creating the .env File
Add a .env file at the root of the project and add the following environment variable:
DUMMY_JSON_BASE_URL=https://dummyjson.com