Skip to content

Commit

Permalink
add section on managing requirements to incremental adoption tutorial
Browse files Browse the repository at this point in the history
  • Loading branch information
IMax153 committed Jun 13, 2024
1 parent 85d128d commit 572532e
Show file tree
Hide file tree
Showing 8 changed files with 276 additions and 58 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ The team in charge of the `TodoRepository` has continued to refactor the `create
Using what we have learned above, your tasks for this exercise include:

- If creation of a `Todo` results in a `CreateTodoError`
- Set the response status code to `500`
- Set the response status code to `404`
- Return a JSON response that conforms to the following `{ "type": "CreateTodoError", "text": <TODO_TEXT> }`

In the editor to the right, modify the code to restore functionality to our server. Please note that there is no one "correct" answer, as there are multiple ways to achieve the desired outcome.
108 changes: 108 additions & 0 deletions content/tutorials/incremental-adoption/300-handling-requirements.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
title: Handling Requirements
excerpt: Learn how to incrementally adopt Effect into your application
section: Incremental Adoption
workspace: express
---

### Handling Requirements

Utilizing `Effect.runPromise` to interop with your existing application is fine when you are just getting started with adopting Effect. However, it will quickly become apparent that this approach does not scale, especially once you start using Effect to manage the requirements of your application and `Layer`s to compose the dependency graph between services.

<Idea>
For a detailed walkthrough of how to manage requirements within your Effect applications, take a look at the <a href="/docs/guides/context-management" target="_blank">Requirements Management</a> section of the documentation.
</Idea>

### Understanding the Problem

To understand the problem, let's take a look at a simple example where we create a `Layer` which logs `"Hello, World"` when constructed. The layer is then provided to two Effect programs which are executed at two separate execution boundaries.

```ts twoslash
import { Console, Effect, Layer } from "effect"

const HelloWorldLive = Layer.effectDiscard(
Console.log("Hello, World!")
)

async function main() {
// Execution Boundary #1
await Effect.succeed(1).pipe(
Effect.provide(HelloWorldLive),
Effect.runPromise
)

// Execution Boundary #2
await Effect.succeed(2).pipe(
Effect.provide(HelloWorldLive),
Effect.runPromise
)
}

main()
/**
* Output:
* Hello, World!
* Hello, World!
*/
```

As you can see from the output, the message `"Hello, World!"` is logged twice. This is because each call to `Effect.provide` will fully construct the dependency graph specified by the `Layer` and then provide it to the Effect program. This can create problems when your layers are meant to encapsulate logic that is only meant to be executed **once** (for example, creating a database connection pool) or when layer construction is **expensive** (for example, fetching a large number of remote assets and caching them in memory).

To solve this problem, we need some sort of top-level, re-usable Effect `Runtime` which contains our fully constructed dependency graph, and then use that `Runtime` to execute our Effect programs instead of the default `Runtime` used by the `Effect.run*` methods.

### Managed Runtimes

The `ManagedRuntime` data type in Effect allows us to create a top-level, re-usable Effect `Runtime` which encapsulates a fully constructed dependency graph. In addition, `ManagedRuntime` gives us explicit control over when resources acquired by the runtime should be disposed.

```ts twoslash
import { Console, Effect, Layer, ManagedRuntime } from "effect"

const HelloWorldLive = Layer.effectDiscard(
Console.log("Hello, World!")
)

// Create a managed runtime from our layer
const runtime = ManagedRuntime.make(HelloWorldLive)

async function main() {
// Execution Boundary #1
await Effect.succeed(1).pipe(
runtime.runPromise
)

// Execution Boundary #2
await Effect.succeed(2).pipe(
runtime.runPromise
)

// Dispose of resources when no longer needed
await runtime.dispose()
}

main()
/**
* Output:
* Hello, World!
*/
```

Some things to note about the program above include:

- `"Hello, World!"` is only logged to the console once
- We no longer have to provide `HelloWorldLive` to each Effect program
- Resources acquired by the `ManagedRuntime` must be manually disposed of

### Exercise

The team in charge of the `TodoRepository` has been hard at work and has managed to convert the `TodoRepository` into a completely Effect-based service complete with a `Layer` for service construction.

Using what we have learned above, your tasks for this exercise include:

- Create a `ManagedRuntime` which takes in the `TodoRepository` layer
- Use the `ManagedRuntime` to run the Effect programs within the Express route handlers
- For any Effect program which may result in a `TodoNotFoundError`:
- Set the response status code to `404`
- Return a JSON response that conforms to the following `{ "type": "TodoNotFound", "id": <TODO_ID> }`
- **BONUS**: properly dispose of the `ManagedRuntime` when the server shuts down

In the editor to the right, modify the code to restore functionality to our server. Please note that there is no one "correct" answer, as there are multiple ways to achieve the desired outcome.
3 changes: 2 additions & 1 deletion src/tutorials/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export const tutorialWorkspaces: ReadonlyRecord<
name: "express",
dependencies: {
...packageJson.dependencies,
express: "latest"
express: "latest",
"@types/express": "latest"
},
shells: [
new WorkspaceShell({ label: "Server", command: "../run src/main.ts" }),
Expand Down
2 changes: 1 addition & 1 deletion src/tutorials/incremental-adoption/0/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ app.patch("/todos/:id", (req, res) => {
// Delete Todo
app.delete("/todos/:id", (req, res) => {
const id = Number.parseInt(req.params.id)
repo.delete(id).then((deleted) => res.json(deleted))
repo.delete(id).then((deleted) => res.json({ deleted }))
})

app.listen(3000, () => {
Expand Down
4 changes: 2 additions & 2 deletions src/tutorials/incremental-adoption/200/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ app.post("/todos", (req, res) => {
Effect.andThen((todo) => res.json(todo)),
Effect.catchTag("CreateTodoError", (error) =>
Effect.sync(() => {
res.status(500).json({
res.status(404).json({
type: error._tag,
text: error.text
text: error.text ?? ""
})
})
),
Expand Down
40 changes: 40 additions & 0 deletions src/tutorials/incremental-adoption/300/main.initial.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Express from "express"
import { TodoRepository } from "./repo"

const app = Express()

app.use(Express.json() as Express.NextFunction)

const repo = new TodoRepository()

// Create Todo
app.post("/todos", (req, res) => {
repo.create(req.body.text).then((todo) => res.json(todo))
})

// Read Todo
app.get("/todos/:id", (req, res) => {
const id = Number.parseInt(req.params.id)
repo.get(id).then((todo) => res.json(todo))
})

// Read Todos
app.get("/todos", (_, res) => {
repo.getAll().then((todos) => res.json(todos))
})

// Update Todo
app.patch("/todos/:id", (req, res) => {
const id = Number.parseInt(req.params.id)
repo.update(id, req.body).then((todo) => res.json(todo))
})

// Delete Todo
app.delete("/todos/:id", (req, res) => {
const id = Number.parseInt(req.params.id)
repo.delete(id).then((deleted) => res.json({ deleted }))
})

app.listen(3000, () => {
console.log("Server listing on port 3000...")
})
54 changes: 45 additions & 9 deletions src/tutorials/incremental-adoption/300/main.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,78 @@
import Express from "express"
import { Effect } from "effect"
import { Effect, ManagedRuntime } from "effect"
import { TodoRepository } from "./repo"

const app = Express()

app.use(Express.json() as Express.NextFunction)

const repo = new TodoRepository()

const runtime = ManagedRuntime.make(TodoRepository.Live)

// Create Todo
app.post("/todos", (req, res) => {
repo.create(req.body.text).then((todo) => res.json(todo))
TodoRepository.create(req.body.text).pipe(
Effect.andThen((todo) => res.json(todo)),
runtime.runPromise
)
})

// Read Todo
app.get("/todos/:id", (req, res) => {
const id = Number.parseInt(req.params.id)
repo.get(id).then((todo) => res.json(todo))
TodoRepository.get(id).pipe(
Effect.andThen((todo) => res.json(todo)),
Effect.catchTag("TodoNotFoundError", () =>
Effect.sync(() => {
res.status(404).json({ type: "TodoNotFound", id })
})
),
runtime.runPromise
)
})

// Read Todos
app.get("/todos", (_, res) => {
repo.getAll().then((todos) => res.json(todos))
TodoRepository.getAll.pipe(
Effect.andThen((todos) => res.json(todos)),
runtime.runPromise
)
})

// Update Todo
app.patch("/todos/:id", (req, res) => {
const id = Number.parseInt(req.params.id)
repo.update(id, req.body).then((todo) => res.json(todo))
TodoRepository.update(id, req.body).pipe(
Effect.andThen((todo) => res.json(todo)),
Effect.catchTag("TodoNotFoundError", () =>
Effect.sync(() => {
res.status(404).json({ type: "TodoNotFound", id })
})
),
runtime.runPromise
)
})

// Delete Todo
app.delete("/todos/:id", (req, res) => {
const id = Number.parseInt(req.params.id)
repo.delete(id).then((deleted) => res.json(deleted))
TodoRepository.delete(id).pipe(
Effect.andThen((deleted) => res.json({ deleted })),
runtime.runPromise
)
})

app.listen(3000, () => {
const server = app.listen(3000, () => {
console.log("Server listing on port 3000...")
})

// Graceful Shutdown
process.on("SIGTERM", shutdown)
process.on("SIGINT", shutdown)

function shutdown() {
server.close(() => {
runtime.dispose().then(() => {
process.exit(0)
})
})
}
Loading

0 comments on commit 572532e

Please sign in to comment.