在 Day21-Day22 我們已經建立好 ConfigLive、LoggerLive、DatabaseLive,並進一步組成應用層服務 MainLive:
ConfigLive ─┐
            ├─(merge)→ AppConfigLive →(provide)→ DatabaseLive → MainLive
LoggerLive ─┘
MainLive → 提供給 HTTPServer(或你的主程式)
今天(Day23)我們延續這個架構,專注在「建構期」的可靠性:
有些時候建構 Layer 也可能失敗,這時可用錯誤處理 API 提供替代方案,讓程式持續運行。
import { Context, Effect, Layer } from "effect"
// 回憶一下我們有哪一些服務
class Config extends Context.Tag("Config")<
  Config,
  {
    readonly getConfig: Effect.Effect<{
      readonly logLevel: string
      readonly connection: string
    }>
  }
>() {}
class Logger extends Context.Tag("Logger")<
  Logger,
  {
    readonly log: (message: string) => Effect.Effect<void>
  }
>() {}
class Database extends Context.Tag("Database")<
  Database,
  { readonly query: (sql: string) => Effect.Effect<unknown> }
>() {}
//         ┌─── Layer<Database, never, Logger | Config>
//         ▼
const DatabaseLivePrimary = Layer.effect(
  Database,
  Effect.gen(function*() {
    // 模擬主資料庫失敗,觸發 Layer.catchAll 的降級路徑
    return yield* Effect.fail(new Error("Primary database connection failed (simulated)"))
  })
)
//         ┌─── Layer<Database, never, Logger>
//         ▼
const InMemoryDatabaseLive = Layer.effect(
  Database,
  Effect.gen(function*() {
    const logger = yield* Logger
    const connection = "in-memory://db"
    return {
      query: (sql: string) =>
        Effect.gen(function* () {
          yield* logger.log(`Executing query (memory): ${sql}`)
          return { result: `Results from ${connection}` }
        })
    }
  })
)
// 失敗時回退到備援
//         ┌─── Layer.Layer<Database, never, Config | Logger>
//         ▼
const DatabaseLive = DatabaseLivePrimary.pipe(
  Layer.catchAll((e) => {
    console.log(`Recovering from error\n${String(e)}`)
    return InMemoryDatabaseLive
  })
)
還記得我們這個系列其中一個目的是學習產品級的軟體開發嗎?這邊我們用實際的例子來說明,當服務發生錯誤而被降級到 in-memory DB 這樣的處理方式有哪些好處與壞處:
我覺得用真實情境來探討這個議題會比較好理解,廢話不多說,我們直接看例子。
connect/PING 逾時。X-Degraded: rate-limit-in-memory)。為什麼可行?限流屬於「控制面」且可接受短暫不一致;跨實例不同步雖有誤差,但比全面停擺好。
為什麼可行?旗標多半是讀多寫少,短暫過期可接受;不涉及交易資料。
為什麼可行?業務是唯讀回應,時效性要求可容忍幾分鐘延遲。
重點:關鍵寫入不要回退到記憶體;要嘛拒絕,要嘛進持久佇列,確保可追溯與不丟單。
//                                                                   ┌─── 備援 Layer
//                                                                   ▼
const database = DatabaseLivePrimary.pipe(Layer.orElse(() => DatabaseFallbackLive))
Effect.runFork(Layer.launch(database))
catchAll 與 orElsecatchAll:你想根據錯誤內容做不同處置(降級、調整設定、紀錄更多脈絡),就用 catchAll 取得錯誤值來決策。orElse:只要「失敗就切換」備援方案,且備援不需要原始錯誤內容時,orElse 最簡潔。在產品級系統中,備援不要一次跳到「全域降級」。先局部、再邊界、最後才是全域,這樣影響面最小、回復也最快。
層級 1(局部/服務內) :某個依賴掛了 → 換同質的備援實作
層級 2(應用/邊界層) :某個業務功能受阻 → 提供 read-only 或簡化替代結果
層級 3(全域/系統級) :整體不可用 → 回應降級頁面/靜態 503 與明確告警
使用者在網站輸入關鍵字執行搜尋,系統需穩定回傳結果清單;若部分依賴故障,必須在不影響對外一致性的前提下降級服務:
Database可用 → 回傳db:${q}結果。Database不可用 → 切換ReadOnly,優先使用Cache;命中回傳快取結果cache:${q},未命中回傳空清單。Cache層亦不可用或沒有 cache 資料 → 回傳空清單。TestMode = "dbSuccess" | "cacheHit" | "cacheMiss" 驗證三種情境。
讓我們來看看程式碼吧!😌
// 服務契約:快取(Read-only)。提供以 key 讀取字串值。
class Cache extends Context.Tag("Cache")<Cache, {
  readonly get: (key: string) => Effect.Effect<string | undefined>
}>() {}
// 服務契約:資料庫。以查詢字串回傳結果清單。
class Database extends Context.Tag("Database")<Database, {
  readonly find: (q: string) => Effect.Effect<ReadonlyArray<string>>
}>() {}
// 服務契約:搜尋。對外暴露單一 run 方法。
class Search extends Context.Tag("Search")<Search, {
  readonly run: (q: string) => Effect.Effect<ReadonlyArray<string>>
}>() {}
// 主路徑:Search 直接委派給 Database(完整功能)
const SearchLivePrimary = Layer.effect(
  Search,
  Effect.gen(function*() {
    const db = yield* Database
    return {
      run: (q: string) => db.find(q)
    }
  })
)
// 次級路徑(唯讀):Search 透過 Cache 回應。命中快取則回傳命中結果,否則回傳空陣列(對外一致)。
const SearchReadOnlyLive = Layer.effect(
  Search,
  Effect.gen(function*() {
    const cache = yield* Cache
    return {
      run: (q: string) =>
        Effect.gen(function*() {
          const hit = yield* cache.get(`search:${q}`)
          return hit ? [hit] : []
        })
    }
  })
)
// 測試模式:控制各種情境
// - "dbSuccess": 資料庫可用(主路徑)
// - "cacheHit": 快取命中(唯讀降級)
// - "cacheMiss": 快取未命中(最終降級)
type TestMode = "cacheMiss" | "cacheHit" | "dbSuccess"
const TEST_MODE = "cacheHit" as TestMode
const QUERY = "hihi"
console.log("TEST_MODE", TEST_MODE)
// 小工具:將多個 Layer 依序串接,若前者失敗則切換到下一個(降級鏈)
function withFallbacks<S, E, R>(
  first: Layer.Layer<S, E, R>,
  ...fallbacks: ReadonlyArray<Layer.Layer<S, E, R>>
): Layer.Layer<S, E, R> {
  return fallbacks.reduce(
    (acc, fb) => acc.pipe(Layer.catchAll(() => fb)),
    first
  )
}
function buildCacheLayer(mode: TestMode): Layer.Layer<Cache, unknown, never> {
  // 情境:快取命中時,直接以快取提供資料
  if (mode === "cacheHit") {
    return Layer.effect(
      Cache,
      Effect.succeed({
        get: (key: string) => Effect.succeed(key === `search:${QUERY}` ? `cache:${QUERY}` : undefined)
      })
    )
  }
  // 真實快取(Redis):此處故意失敗以模擬不可用
  const CacheRedisLive = Layer.effect(
    Cache,
    Effect.fail(new Error("Redis unavailable (simulated)"))
  )
  // 後備快取(記憶體):永遠可用,但預設沒有資料
  const CacheInMemoryLive = Layer.effect(
    Cache,
    Effect.succeed({
      get: (_key: string) => Effect.succeed<string | undefined>(undefined)
    })
  )
  // 先試 Redis,失敗則降級到記憶體快取
  return CacheRedisLive.pipe(Layer.catchAll(() => CacheInMemoryLive))
}
function buildDatabaseLayer(mode: TestMode): Layer.Layer<Database, unknown, never> {
  // 成功情境:資料庫可用
  if (mode === "dbSuccess") {
    return Layer.effect(Database, Effect.succeed({ find: (q: string) => Effect.succeed([`db:${q}`]) }))
  }
  // 失敗情境:模擬主資料庫連線失敗
  return Layer.effect(Database, Effect.fail(new Error("Primary database connection failed (simulated)")))
}
// 依 TEST_MODE 建立對應的快取與資料庫 Layer
const CacheLayer = buildCacheLayer(TEST_MODE)
const DatabaseLayer = buildDatabaseLayer(TEST_MODE)
// 最終降級:回傳空清單,維持服務可用性
const DegradedSearchLive = Layer.succeed(Search, { run: (_q: string) => Effect.succeed([]) })
// 組合降級鏈:Primary -> ReadOnly -> Degraded
const SearchLive = withFallbacks(
  SearchLivePrimary.pipe(Layer.provide(DatabaseLayer)),
  SearchReadOnlyLive.pipe(Layer.provide(CacheLayer)),
  DegradedSearchLive
)
// 系統注入:此例中只需提供 Search 即可
const MainLive = SearchLive
// 應用程式:呼叫 Search.run 並回傳結果
const program = Effect.gen(function*() {
  const search = yield* Search
  const results = yield* search.run(QUERY)
  return results
})
// 提供 Layer 並執行
const runnable = Effect.provide(program, MainLive)
Effect.runPromise(runnable).then(console.log).catch(console.error)
SearchLivePrimary 依賴 DatabaseLayer,成功回傳 db:${q}。withFallbacks(Primary, ReadOnly, Degraded) 依序降級。SearchReadOnlyLive 依賴 CacheLayer,命中回 cache:${q};未命中回 []。DegradedSearchLive 回傳 [];與 ReadOnly 未命中一致,滿足對外一致性。"dbSuccess" | "cacheHit" | "cacheMiss" 控制三種情境,便於示範與測試。示例使用 TEST_MODE = "cacheHit"。QUERY 常數(例如 "hihi")搭配 cache:${QUERY} 模擬快取命中條件。關鍵要點:
orElse;需根據錯誤決策用 catchAll。