TL;DR (Quick Summary)#
- The Goal: Combine
Schema(Validation),Context(Dependency Injection),Layer(Wiring), andcatchTag(Error Handling) into a real-world Express server. @effect/platform: The official Effect ecosystem library for building HTTP servers (Node.js, Bun, Cloudflare Workers) natively in the Effect paradigm.- The Router: Effect provides
HttpRouter, allowing you to map HTTP methods to Effect pipelines. - The Edge: We mount the Effect Router onto a native Node.js/Express server and run it using
NodeServer.listen.
1. Introduction: The Final Architecture#
Over the last 14 episodes, we transformed how we write TypeScript. We abandoned classes, mutations, try/catch, and global variables.
Today, we build a POST /users endpoint. Our architecture will look like this:
- The API Layer: Receives the HTTP request, validates the JSON payload via
Schema. - The Business Layer: Hashes the password and delegates to the Database Service.
- The Data Layer: Saves the user to the database, simulating network delays.
- The App Edge: Provides all dependencies via
Layerand starts the Express server.
Let’s begin.
2. Step 1: Dependencies and Schemas#
First, install the platform libraries.
npm install @effect/platform @effect/platform-node @effect/schemaNow, define our Validation Schema and our Discriminated Errors.
import { Schema } from "@effect/schema";
import { Data } from "effect";
// 🟢 1. Input Validation
export const CreateUserRequest = Schema.Struct({
email: Schema.String.pipe(Schema.nonEmptyString()),
password: Schema.String.pipe(Schema.minLength(8))
});
// 🟢 2. Domain Errors
export class UserAlreadyExistsError extends Data.TaggedError("UserAlreadyExists")<{
readonly email: string;
}> {}
export class DatabaseError extends Data.TaggedError("DatabaseError")<{
readonly message: string;
}> {}3. Step 2: The Database Service (Context & Layer)#
We define our DatabaseService interface, create a Context.Tag, and build a mock Layer for it.
import { Context, Effect, Layer } from "effect";
// 🟢 1. The Interface
export interface DatabaseService {
readonly saveUser: (email: string) => Effect.Effect<void, UserAlreadyExistsError | DatabaseError>;
}
// 🟢 2. The Tag (Injection Token)
export const DatabaseTag = Context.GenericTag<DatabaseService>("DatabaseService");
// 🟢 3. The Implementation Layer
export const DatabaseLive = Layer.succeed(DatabaseTag, {
saveUser: (email) => Effect.sync(() => {
console.log(`[DB] Saving user ${email}...`);
// Simulate checking if user exists
if (email === "test@test.com") {
return Effect.fail(new UserAlreadyExistsError({ email }));
}
return Effect.succeed(undefined);
})
});4. Step 3: The API Route#
We use @effect/platform to define our route. This is where the magic happens. We extract the JSON body, validate it using Schema, and call the Database Service.
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "@effect/platform";
import { Effect } from "effect";
// 🟢 The Route Handler
const createUserRoute = HttpRouter.post("/users", Effect.pipe(
// 1. Get the HTTP Request
HttpServerRequest.HttpServerRequest,
// 2. Parse the JSON body against our Schema!
Effect.flatMap(req => req.schemaBodyJson(CreateUserRequest)),
// 3. Ask for the DatabaseService
Effect.flatMap(payload => Effect.pipe(
DatabaseTag,
Effect.flatMap(db => db.saveUser(payload.email))
)),
// 4. If everything succeeded, return 201 Created
Effect.map(() => HttpServerResponse.empty({ status: 201 })),
// 5. ERROR HANDLING: Match specific errors to HTTP Status Codes
Effect.catchTags({
ParseError: (err) => Effect.succeed(HttpServerResponse.text(String(err), { status: 400 })),
UserAlreadyExists: (err) => Effect.succeed(HttpServerResponse.text(`User ${err.email} exists`, { status: 409 })),
DatabaseError: () => Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 }))
})
));
// 🟢 Create the Router
const MainRouter = HttpRouter.empty.pipe(
HttpRouter.append(createUserRoute)
);Take a moment to admire createUserRoute.
It is mathematically perfect. There is no try/catch. The validation ParseError and the database UserAlreadyExistsError are caught perfectly using Effect.catchTags. If you forget to handle an error, the code will not compile!
5. Step 4: The Edge (Booting the Server)#
Finally, we wire our MainRouter into a Node.js Express server using the platform adapter, provide our DatabaseLive layer, and run the program!
import { NodeHttpServer } from "@effect/platform-node";
import { Effect, Layer } from "effect";
import { createServer } from "node:http";
// 1. Convert the Effect Router into a native HTTP App
const HttpApp = HttpRouter.toHttpApp(MainRouter);
// 2. Create the Node.js Server Layer (Running on Port 3000)
const ServerLive = NodeHttpServer.server.layer(() => createServer(), { port: 3000 });
// 3. Wire everything together!
const AppLive = Layer.mergeAll(
ServerLive, // The Web Server
DatabaseLive // The Database Service
);
// 4. Run the application
const runnable = Effect.pipe(
HttpApp,
Effect.provide(AppLive) // Inject the massive layer graph!
);
// Boot!
Effect.runFork(runnable);
console.log("🚀 Server running on http://localhost:3000");6. Conclusion: The Functional Promise Delivered#
Over the course of this module, we have completely rewired how we think about software engineering.
You learned that:
- Immutability eliminates shared-state bugs.
- Algebraic Data Types eliminate impossible states.
- Option and Either eliminate
nulland thrown exceptions. - Pipe and Flow eliminate the Pyramid of Doom.
- Effect Fibers eliminate resource-wasting native Promises.
- Schema eliminates I/O corruption.
- Context eliminates global singletons.
When you combine all of these into a single architecture, you achieve something incredibly rare in the software industry: Sleep.
You can deploy your backend on a Friday afternoon, knowing with mathematical certainty that your dependency graph is resolved, your errors are caught, and your types are bulletproof.
Thank you for joining me on this 15-part journey into the Effect-TS ecosystem.
Keep shipping pure functions!

