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.
O contrato: serviços e erros
Section titled “O contrato: serviços e erros”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):
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:
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:
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:
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 }), } }),)O caso de uso: orquestração com gen
Section titled “O caso de uso: orquestração com gen”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:
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ê anotarO 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:
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:
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 aquiBunRuntime.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.
Palavras-chave
Section titled “Palavras-chave”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