Skip to content

Juntando tudo: uma fatia de ponta a ponta

Cada peça do Effect apareceu até aqui em fragmentos: a descrição como valor, os generators, o pipe, a borda, os erros tipados, os Layers. Falta vê-las funcionando juntas. Esta página pega uma fatia realista de aplicação, carregar o perfil de um usuário, e a percorre inteira, de dentro para fora: do contrato mais interno até o ponto onde o runtime finalmente executa.

O caminho tem quatro estações. O contrato declara os serviços e os erros. A implementação constrói cada serviço como um Layer, e é onde moram as folhas de fronteira. O caso de uso orquestra tudo num Effect.gen. E a borda trata os erros, aplica as preocupações transversais, fornece as dependências e roda. No fim, o que era código espalhado por vários módulos vira um único valor.

Antes de qualquer implementação, você descreve o que existe. Os erros são dados com uma tag, não throw, para viajarem tipados no canal E (o porquê está em a borda e os erros):

domain/errors.ts
import { Data } from "effect"
export class UserNotFound extends Data.TaggedError("UserNotFound")<{
readonly id: string
}> {}
export class DbError extends Data.TaggedError("DbError")<{
readonly cause: unknown
}> {}

Os serviços são declarados como tags, que servem ao mesmo tempo de interface e de chave de injeção (ver Layers e dependências). Um repositório de usuários depende de um banco, então há duas tags, e a de baixo aparece no canal R de quem a usa:

domain/services.ts
import { Context, Effect } from "effect"
import type { DbError, UserNotFound } from "./errors"
export interface User { readonly id: string; readonly name: string; readonly email: string }
export class Database extends Context.Tag("App/Database")<Database, {
readonly query: <A>(sql: string, params: ReadonlyArray<unknown>) => Effect.Effect<ReadonlyArray<A>, DbError>
}>() {}
export class UserRepository extends Context.Tag("App/UserRepository")<UserRepository, {
readonly findById: (id: string) => Effect.Effect<User, UserNotFound | DbError>
}>() {}

Repare que nada foi implementado. O contrato é só a forma: quais operações existem, o que produzem, como falham. É o suficiente para escrever o caso de uso antes mesmo de existir um banco de verdade.

A implementação: Layers e as folhas de fronteira

Section titled “A implementação: Layers e as folhas de fronteira”

Aqui o contrato ganha corpo, e é onde o programa encosta no mundo. O banco é um recurso com ciclo de vida (abre um pool, precisa fechá-lo no shutdown), então usa Layer.scoped com acquireRelease. O Effect.tryPromise é a folha de fronteira que embrulha a chamada real do driver e traduz qualquer rejeição para um DbError:

infra/database.ts
import { Config, Effect, Layer, Redacted } from "effect"
import { Database } from "../domain/services"
import { DbError } from "../domain/errors"
export const DatabaseLive = Layer.scoped(
Database,
Effect.gen(function* () {
const url = yield* Config.redacted("DATABASE_URL")
const pool = yield* Effect.acquireRelease(
Effect.sync(() => createPool(Redacted.value(url))),
(pool) => Effect.promise(() => pool.end()), // liberado sempre, mesmo em erro
)
return {
query: (sql, params) =>
Effect.tryPromise({
try: () => pool.query(sql, params), // a folha: o efeito real acontece aqui
catch: (cause) => new DbError({ cause }),
}),
}
}),
)

O repositório é construído com Layer.effect porque consome outro serviço: ele faz yield* Database e usa o resultado. A ausência de linha para um id inexistente vira uma falha de domínio explícita, UserNotFound, em vez de um undefined que escaparia silencioso:

infra/user-repository.ts
import { Effect, Layer } from "effect"
import { Database, UserRepository, type User } from "../domain/services"
import { UserNotFound } from "../domain/errors"
export const UserRepositoryLive = Layer.effect(
UserRepository,
Effect.gen(function* () {
const db = yield* Database // consome Database
return {
findById: (id) =>
Effect.gen(function* () {
const rows = yield* db.query<User>("SELECT * FROM users WHERE id = $1", [id])
const user = rows[0]
if (user === undefined) {
return yield* Effect.fail(new UserNotFound({ id }))
}
return user
}),
}
}),
)

Com os contratos no lugar, a lógica de negócio é pura composição. Ela não conhece pool, driver nem SQL; só pede o serviço e encadeia os passos. É aqui que o contágio trabalha a seu favor: ao fazer yield* UserRepository, a dependência aparece sozinha no canal R, e o erro possível aparece sozinho no canal E:

use-cases/load-profile.ts
import { Effect } from "effect"
import { UserRepository } from "../domain/services"
export const loadProfile = (id: string) =>
Effect.gen(function* () {
const users = yield* UserRepository
const user = yield* users.findById(id)
return { name: user.name, email: user.email }
})
// tipo inferido: Effect<Profile, UserNotFound | DbError, UserRepository>
// ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^
// o E e o R subiram sozinhos, sem você anotar

O tipo é a documentação que não mente: este programa produz um Profile, pode falhar de duas formas conhecidas, e precisa de um UserRepository para rodar. Nada disso executou; loadProfile("u_123") é só uma descrição.

A borda: tratar, transformar, fornecer, rodar

Section titled “A borda: tratar, transformar, fornecer, rodar”

O caso de uso ainda não é executável: falta satisfazer o R e decidir o que fazer com o E. Isso acontece na borda, onde o gen (o que o programa faz) encontra o pipe (como ele será executado). Antes, monta-se o grafo de dependências, lendo de baixo para cima: o repositório precisa do banco, então o banco é fornecido a ele, e a dependência some do tipo:

layers.ts
import { Layer } from "effect"
import { UserRepositoryLive } from "./infra/user-repository"
import { DatabaseLive } from "./infra/database"
export const AppLive = UserRepositoryLive.pipe(
Layer.provide(DatabaseLive),
)
// Layer<UserRepository, ConfigError, never>

Na borda, o pipe acrescenta uma camada de cada vez. O catchTag resolve a falha esperada virando uma resposta, o map formata o sucesso, e retry, timeout e tracing entram como comportamento transversal, sem tocar na lógica. Por último, Effect.provide(AppLive) injeta o grafo e zera o canal R:

http/handler.ts
import { Effect } from "effect"
import { BunRuntime } from "@effect/platform-bun"
import { loadProfile } from "../use-cases/load-profile"
import { AppLive } from "../layers"
const handler = (id: string) =>
loadProfile(id).pipe(
Effect.map((profile) => ({ status: 200, body: profile })),
Effect.catchTag("UserNotFound", (e) =>
Effect.succeed({ status: 404, body: `usuário ${e.id} não existe` }),
),
Effect.retry({ times: 2 }), // transversal: não suja o caso de uso
Effect.timeout("5 seconds"),
Effect.withSpan("load-profile"),
Effect.provide(AppLive), // R: UserRepository → never
)
// o mundo impuro começa aqui
BunRuntime.runMain(handler("u_123"))

Depois do provide, o R chegou a never: o programa está fechado e pode rodar. O catchTag tirou o UserNotFound do canal E; o que sobra ali (o DbError, o timeout) é justamente o que o runMain vai transformar em falha na ponta, se acontecer. E é só no runMain que qualquer coisa de fato executa: o pool é aberto, a query roda, o efeito acontece.

Fatia: contrato, implementação, caso de uso, borda, grafo de dependências Domain: Data.TaggedError, Context.Tag, Shape, UserNotFound, DbError Infra: Layer.scoped, Layer.effect, acquireRelease, Effect.tryPromise, folha de fronteira Caso de uso: Effect.gen, yield*, canais A/E/R, contágio Borda: pipe, catchTag, Effect.retry, Effect.timeout, Effect.withSpan, Effect.provide, BunRuntime.runMain, R para never Composição: Layer.provide, AppLive, injeção por tipo, test double