mcpField
E
effect-best-practices

effect-best-practices

github.com/millionco開発3.6k62 回インストール30 週間前に更新
リポジトリ

effect-best-practices について

Effect-TS のサービス設計、エラー、スキーマ、状態管理、可観測性に関する一貫した実践を定めるスキルです。Effect-TS のコードベースを開発・保守するチーム向けです。

主な機能

  1. 01Effect.Service と依存関係のパターン
  2. 02タグ付きエラーと明示的な処理
  3. 03ブランド付き ID とスキーマの指針
  4. 04Effect.fn によるトレースと構造化ログ
  5. 05React Atom の状態と結果のパターン

活用シーン

  1. 01Effect-TS のサービス規約の統一
  2. 02エラーとスキーマ設計のレビュー
  3. 03Effect を使った React 状態の構築
  4. 04トレースと構造化ログの改善

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/millionco/expect --skill effect-best-practices

インストール後、Claude Code で/effect-best-practicesを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
anti-patterns.md
effect-atom-patterns.md
error-patterns.md
layer-patterns.md
observability-patterns.md
rpc-cluster-patterns.md
schema-patterns.md
service-patterns.md

詳細

Effect-TS のベストプラクティス

このスキルでは、Effect-TS のコードベース向けに、明確で一貫性のあるパターンを徹底します。これらのパターンは、型安全性、テスト容易性、可観測性、保守性を高めます。

クイックリファレンス:重要なルール

カテゴリ推奨非推奨
サービスaccessors: true を指定した Effect.Serviceビジネスロジックに Context.Tag
依存関係サービス内の dependencies: [Dep.Default]使用箇所での手動の Layer.provide
エラーmessage フィールドを持つ Schema.TaggedErrorプレーンなクラスや汎用的な Error
エラーの具体性UserNotFoundError、SessionExpiredError汎用的な NotFoundError、BadRequestError
エラー処理catchTag/catchTagscatchAll や mapError
IDSchema.UUID.pipe(Schema.brand("@App/EntityId"))エンティティ ID にプレーンな string
関数Effect.fn("Service.method")無名ジェネレーター
ロギング構造化データを使った Effect.logconsole.log
設定検証を伴う Config.*process.env を直接使用
オプション両方のケースを扱う Option.matchOption.getOrThrow
Null 許容性ドメイン型での Option<T>null/undefined
Atomコンポーネントの外での Atom.makeレンダリング中に Atom を作成
Atom の状態グローバル状態に Atom.keepAlive永続状態で keepAlive を忘れる
Atom の更新React コンポーネント内での useAtomSetReact から命令的に Atom.update を呼び出す
Atom のクリーンアップ副作用に対する get.addFinalizer()イベントリスナーのクリーンアップ漏れ
Atom の結果onErrorTag を使った Result.builderロード中/エラー状態を無視する

サービス定義パターン

ビジネスロジックのサービスには、必ず Effect.Service を使用してください。これにより、アクセサーが自動生成され、組み込みの Default レイヤーが提供され、依存関係を適切に宣言できます。

import { Effect } from "effect";

export class UserService extends Effect.Service<UserService>()("UserService", {
  accessors: true,
  dependencies: [UserRepo.Default, CacheService.Default],
  effect: Effect.gen(function* () {
    const repo = yield* UserRepo;
    const cache = yield* CacheService;

    const findById = Effect.fn("UserService.findById")(function* (id: UserId) {
      const cached = yield* cache.get(id);
      if (Option.isSome(cached)) return cached.value;

      const user = yield* repo.findById(id);
      yield* cache.set(id, user);
      return user;
    });

    const create = Effect.fn("UserService.create")(function* (data: CreateUserInput) {
      const user = yield* repo.create(data);
      yield* Effect.log("User created", { userId: user.id });
      return user;
    });

    return { findById, create };
  }),
}) {}

// Usage - dependencies are already wired
const program = Effect.gen(function* () {
  const user = yield* UserService.findById(userId);
  return user;
});

// At app root
const MainLive = Layer.mergeAll(UserService.Default, OtherService.Default);

Context.Tag を使用してもよい場合:

  • 実行時に注入するインフラストラクチャ(Cloudflare KV、ワーカーバインディング)
  • リソースを外部から提供するファクトリーパターン

詳しいパターンについては、references/service-patterns.md を参照してください。

エラー定義パターン

エラーには必ず Schema.TaggedError を使用してください。これによりシリアライズ可能になり(RPC に必須)、一貫した構造を実現できます。

import { Schema } from "effect";
import { HttpApiSchema } from "@effect/platform";

export class UserNotFoundError extends Schema.TaggedError<UserNotFoundError>()(
  "UserNotFoundError",
  {
    userId: UserId,
    message: Schema.String,
  },
  HttpApiSchema.annotations({ status: 404 }),
) {}

export class UserCreateError extends Schema.TaggedError<UserCreateError>()(
  "UserCreateError",
  {
    message: Schema.String,
    cause: Schema.optional(Schema.String),
  },
  HttpApiSchema.annotations({ status: 400 }),
) {}

エラー処理には catchTag/catchTags を使用します:

// CORRECT - preserves type information
yield *
  repo.findById(id).pipe(
    Effect.catchTag("DatabaseError", (err) =>
      Effect.fail(new UserNotFoundError({ userId: id, message: "Lookup failed" })),
    ),
    Effect.catchTag("ConnectionError", (err) =>
      Effect.fail(new ServiceUnavailableError({ message: "Database unreachable" })),
    ),
  );

// CORRECT - multiple tags at once
yield *
  effect.pipe(
    Effect.catchTags({
      DatabaseError: (err) =>
        Effect.fail(new UserNotFoundError({ userId: id, message: err.message })),
      ValidationError: (err) =>
        Effect.fail(new InvalidEmailError({ email: input.email, message: err.message })),
    }),
  );
汎用的なエラーより明示的なエラーを優先する

異なる失敗理由には、それぞれ独自のエラー型を用意してください。 複数の失敗パターンを汎用的な HTTP エラーにまとめないでください。

// WRONG - Generic errors lose information
export class NotFoundError extends Schema.TaggedError<NotFoundError>()(
  "NotFoundError",
  { message: Schema.String },
  HttpApiSchema.annotations({ status: 404 }),
) {}

// Then mapping everything to it:
Effect.catchTags({
  UserNotFoundError: (err) => Effect.fail(new NotFoundError({ message: "Not found" })),
  ChannelNotFoundError: (err) => Effect.fail(new NotFoundError({ message: "Not found" })),
  MessageNotFoundError: (err) => Effect.fail(new NotFoundError({ message: "Not found" })),
});
// Frontend gets useless: { _tag: "NotFoundError", message: "Not found" }
// Which resource? User? Channel? Message? Can't tell!
// CORRECT - Explicit domain errors with rich context
export class UserNotFoundError extends Schema.TaggedError<UserNotFoundError>()(
  "UserNotFoundError",
  { userId: UserId, message: Schema.String },
  HttpApiSchema.annotations({ status: 404 }),
) {}

export class ChannelNotFoundError extends Schema.TaggedError<ChannelNotFoundError>()(
  "ChannelNotFoundError",
  { channelId: ChannelId, message: Schema.String },
  HttpApiSchema.annotations({ status: 404 }),
) {}

export class SessionExpiredError extends Schema.TaggedError<SessionExpiredError>()(
  "SessionExpiredError",
  {
    sessionId: SessionId,
    expiredAt: Schema.DateTimeUtc,
    message: Schema.String,
  },
  HttpApiSchema.annotations({ status: 401 }),
) {}

// Frontend can now show specific UI:
// - UserNotFoundError → "User doesn't exist"
// - ChannelNotFoundError → "Channel was deleted"
// - SessionExpiredError → "Your session expired. Please log in again."

エラーの再マッピングと再試行のパターンについては、references/error-patterns.md を参照してください。

スキーマとブランド型のパターン

サービス境界をまたいだ型安全性を確保するため、すべてのエンティティ ID にブランドを付けてください。

import { Schema } from "effect";

// Entity IDs - always branded
export const UserId = Schema.UUID.pipe(Schema.brand("@App/UserId"));
export type UserId = Schema.Schema.Type<typeof UserId>;

export const OrganizationId = Schema.UUID.pipe(Schema.brand("@App/OrganizationId"));
export type OrganizationId = Schema.Schema.Type<typeof OrganizationId>;

// Domain types - use Schema.Struct
export const User = Schema.Struct({
  id: UserId,
  email: Schema.String,
  name: Schema.String,
  organizationId: OrganizationId,
  createdAt: Schema.DateTimeUtc,
});
export type User = Schema.Schema.Type<typeof User>;

// Input types for mutations
export const CreateUserInput = Schema.Struct({
  email: Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)),
  name: Schema.String.pipe(Schema.minLength(1)),
  organizationId: OrganizationId,
});
export type CreateUserInput = Schema.Schema.Type<typeof CreateUserInput>;

ブランドを付けない場合:

  • サービス境界をまたがない単純な文字列(URL、ファイルパス)
  • プリミティブな設定値

変換処理や高度なパターンについては、references/schema-patterns.md を参照してください。

Effect.fn を使った関数パターン

サービスメソッドには必ず Effect.fn を使用してください。これにより、適切なスパン名でトレースが自動的に行われます。

// CORRECT - Effect.fn with descriptive name
const findById = Effect.fn("UserService.findById")(function* (id: UserId) {
  yield* Effect.annotateCurrentSpan("userId", id);
  const user = yield* repo.findById(id);
  return user;
});

// CORRECT - Effect.fn with multiple parameters
const transfer = Effect.fn("AccountService.transfer")(function* (
  fromId: AccountId,
  toId: AccountId,
  amount: number,
) {
  yield* Effect.annotateCurrentSpan("fromId", fromId);
  yield* Effect.annotateCurrentSpan("toId", toId);
  yield* Effect.annotateCurrentSpan("amount", amount);
  // ...
});

レイヤーの合成

依存関係は使用箇所ではなく、サービス内で宣言してください。

// CORRECT - dependencies in service definition
export class OrderService extends Effect.Service<OrderService>()("OrderService", {
  accessors: true,
  dependencies: [UserService.Default, ProductService.Default, PaymentService.Default],
  effect: Effect.gen(function* () {
    const users = yield* UserService;
    const products = yield* ProductService;
    const payments = yield* PaymentService;
    // ...
  }),
}) {}

// At app root - simple merge
const AppLive = Layer.mergeAll(
  OrderService.Default,
  // Infrastructure layers (intentionally not in dependencies)
  DatabaseLive,
  RedisLive,
);

テスト用レイヤーと設定に依存するレイヤーについては、references/layer-patterns.md を参照してください。

オプションの処理

Option.getOrThrowは絶対に使用しないでください。常に両方のケースを明示的に処理します。

// CORRECT - explicit handling
yield *
  Option.match(maybeUser, {
    onNone: () => Effect.fail(new UserNotFoundError({ userId, message: "Not found" })),
    onSome: (user) => Effect.succeed(user),
  });

// CORRECT - with getOrElse for defaults
const name = Option.getOrElse(maybeName, () => "Anonymous");

// CORRECT - Option.map for transformations
const upperName = Option.map(maybeName, (n) => n.toUpperCase());

Effect Atom(フロントエンドの状態)

Effect Atomは、Effectと連携するReact向けのリアクティブな状態管理を提供します。

基本的なAtom
import { Atom } from "@effect-atom/atom-react";

// Define atoms OUTSIDE components
const countAtom = Atom.make(0);

// Use keepAlive for global state that should persist
const userPrefsAtom = Atom.make({ theme: "dark" }).pipe(Atom.keepAlive);

// Atom families for per-entity state
const modalAtomFamily = Atom.family((type: string) =>
  Atom.make({ isOpen: false }).pipe(Atom.keepAlive),
);
Reactとの連携
import { useAtomValue, useAtomSet, useAtom, useAtomMount } from "@effect-atom/atom-react"

function Counter() {
    const count = useAtomValue(countAtom)           // Read only
    const setCount = useAtomSet(countAtom)          // Write only
    const [value, setValue] = useAtom(countAtom)    // Read + write

    return <button onClick={() => setCount((c) => c + 1)}>{count}</button>
}

// Mount side-effect atoms without reading value
function App() {
    useAtomMount(keyboardShortcutsAtom)
    return <>{children}</>
}
Result.builderによる結果の処理

Effect Atomの結果をレンダリングするには、Result.builderを使用してください。onErrorTagによるチェーン可能なエラー処理を提供します。

import { Result } from "@effect-atom/atom-react"

function UserProfile() {
    const userResult = useAtomValue(userAtom) // Result<User, Error>

    return Result.builder(userResult)
        .onInitial(() => <div>Loading...</div>)
        .onErrorTag("NotFoundError", () => <div>User not found</div>)
        .onError((error) => <div>Error: {error.message}</div>)
        .onSuccess((user) => <div>Hello, {user.name}</div>)
        .render()
}
副作用のあるAtom
const scrollYAtom = Atom.make((get) => {
  const onScroll = () => get.setSelf(window.scrollY);

  window.addEventListener("scroll", onScroll);
  get.addFinalizer(() => window.removeEventListener("scroll", onScroll)); // REQUIRED

  return window.scrollY;
}).pipe(Atom.keepAlive);

ファミリー、localStorage、アンチパターンなどの完全なパターンについては、references/effect-atom-patterns.mdを参照してください。

RPCとクラスターのパターン

RPCコントラクトとクラスターのワークフローについては、以下を参照してください。

  • references/rpc-cluster-patterns.md - RpcGroup、Workflow.make、Activityのパターン

アンチパターン(禁止)

以下のパターンは絶対に許容されません。

// FORBIDDEN - runSync/runPromise inside services
const result = Effect.runSync(someEffect); // Never do this

// FORBIDDEN - throw inside Effect.gen
yield *
  Effect.gen(function* () {
    if (bad) throw new Error("No!"); // Use Effect.fail instead
  });

// FORBIDDEN - catchAll losing type info
yield * effect.pipe(Effect.catchAll(() => Effect.fail(new GenericError())));

// FORBIDDEN - console.log
console.log("debug"); // Use Effect.log

// FORBIDDEN - process.env directly
const key = process.env.API_KEY; // Use Config.string("API_KEY")

// FORBIDDEN - null/undefined in domain types
type User = { name: string | null }; // Use Option<string>

理由を含む完全な一覧については、references/anti-patterns.mdを参照してください。

可観測性

// Structured logging
yield * Effect.log("Processing order", { orderId, userId, amount });

// Metrics
const orderCounter = Metric.counter("orders_processed");
yield * Metric.increment(orderCounter);

// Config with validation
const config = Config.all({
  port: Config.integer("PORT").pipe(Config.withDefault(3000)),
  apiKey: Config.secret("API_KEY"),
  maxRetries: Config.integer("MAX_RETRIES").pipe(
    Config.validate({ message: "Must be positive", validation: (n) => n > 0 }),
  ),
});

メトリクスとトレースのパターンについては、references/observability-patterns.mdを参照してください。

参考ファイル

詳細なパターンについては、references/ディレクトリにある以下の参考ファイルを参照してください。

  • service-patterns.md - サービスの定義、Effect.fn、Context.Tagの例外
  • error-patterns.md - Schema.TaggedError、エラーの再マッピング、再試行のパターン
  • schema-patterns.md - ブランド型、変換、Schema.Class
  • layer-patterns.md - 依存関係の合成、テスト用レイヤー
  • rpc-cluster-patterns.md - RpcGroup、Workflow、Activityのパターン
  • effect-atom-patterns.md - Atom、ファミリー、Reactフック、Resultの処理
  • anti-patterns.md - 禁止パターンの完全な一覧
  • observability-patterns.md - ロギング、メトリクス、設定のパターン

コメント