🧭 La boussole — « Je veux… → j'utilise… »
Le guide de sélection rapide. En cas de doute, commence ici.
| Je veux… | J'utilise… |
|---|---|
| Un record / objet ordinaire | Schema.Struct(...) + interface du même nom |
| Un ID scalaire / value object | Schéma brandé contraint |
| Un état interne de workflow | Data.TaggedEnum + $match exhaustif |
| Une union taguée qui traverse une frontière | Schema.TaggedUnion(...) (.cases, .guards, .match) |
| Une erreur typée attendue | Schema.TaggedErrorClass |
| Décoder un payload inconnu | Schema.decodeUnknownEffect(...) |
| Un service applicatif | Context.Service + Layer.effect + Service.of(...) |
| Une méthode de service publique | Effect.fn("Domaine.operation") |
| Lire la config runtime | Config dans les layers — jamais process.env |
| Réessayer une opération transitoire | Effect.retry(...) avec un Schedule borné |
| Un worker de polling | runPass().pipe(Effect.repeat(Schedule.spaced(...))) |
| Un cache par clé avec TTL + dédup | Cache.make(...) / Cache.makeWith(...) |
| Mémoïser un seul résultat | Effect.cached(...) / Effect.cachedWithTTL(...) |
| Une source d'événements | Stream consommé via Stream.runForEach + Effect.forkScoped |
| Batcher N clés en 1 appel (vrai endpoint batch) | Effect.request(...) + RequestResolver |
| Un appel HTTP sortant | HttpClient Effect + décodage Schema |
| Tester du temps (sleep, retry, timeout) | TestClock, jamais de vrai sleep |
| Synchroniser des fibers en test | Deferred, Queue, Latch, Ref |
🧬 Schema & modèles de données SCHEMA.md
DTO, contrats wire, brands, variants, décodeurs.
📦 Le record par défaut
export const User = Schema.Struct({
id: UserId,
name: Schema.NonEmptyString,
email: Schema.optionalKey(Schema.String),
})
export interface User
extends Schema.Schema.Type<typeof User> {}
.annotate({ identifier })seulement si un outil le consomme (OpenAPI, RPC…)- Réutilise les champs :
User.fields.name,Schema.fieldsAssign(...)
🔓 Construire vs décoder
schema.make(...) | construction de confiance (throw) |
schema.makeEffect(...) | échec dans le canal d'erreur Effect |
decodeUnknownEffect | frontières non fiables — le défaut |
decodeUnknownSync | scripts, tests, startup uniquement |
decodeUnknownResult | code pur sans Effect |
💡 Jamais de cast pour esquiver la validation.
❓ Optionalité
Schema.optionalKey(...)→ clé JSON absente (le cas courant)Schema.optional(...)→ seulement siundefinedexplicite fait partie du contratNullOr/NullishOr→ seulement si le null est vraiment encodé- Valeurs par défaut = champs requis, défaut appliqué au décodage
🎭 Variants : interne vs frontière
// Interne : contrôle de flux
type Step = Data.TaggedEnum<{
Continue: { readonly cursor: number }
Finished: { readonly count: number }
}>
const Step = Data.taggedEnum<Step>()
Step.$match(next, { Continue: ..., Finished: ... })
// Frontière : décodage / persistance / wire
const Event = Schema.TaggedUnion({
Started: { runId: RunId },
Finished: { runId: RunId, result: Schema.Json },
})
- Discriminant externe (
type,kind) :Schema.tag(...)+Schema.toTaggedUnion("type")
💥 Erreurs typées
export class PersistenceError extends
Schema.TaggedErrorClass<PersistenceError>()(
"UserRepo.PersistenceError",
{ operation: Schema.String, cause: Schema.Defect() },
) {}
- Mappe les échecs d'infra en erreurs domaine aux frontières de service
- Ajoute un label
operationpour le diagnostic Schema.Defect()pour les payloads de type defect
🏷️ Valeurs nominales
- IDs et value objects → schémas brandés contraints
- Contraintes de schéma normales avant
Schema.brand(...) Schema.fromBrand(...)seulement si le projet utilise déjà des constructeursBrand
🧩 Services, Layers & modules SERVICES_LAYERS.md
Tags, surfaces de module, câblage runtime, Effect.fn.
🏗️ Le pattern module canonique
// user-repo.ts
export interface Interface {
readonly get: (id: UserId) => Effect.Effect<User, NotFound | PersistenceError>
}
export class Service extends Context.Service<Service, Interface>()("@app/UserRepo") {}
export const layer = Layer.effect(Service, Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
const get = Effect.fn("UserRepo.get")(function* (id: UserId) { /* ... */ })
return Service.of({ get })
}))
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("UserRepo.NotFound", { id: UserId }) {}
export * as UserRepo from "./user-repo.js" // auto-export du namespace
- Le consommateur :
const repo = yield* UserRepo.Service - N'exporte que la surface intentionnelle ; les helpers restent privés
Effect.fn("Domaine.op")pour toute méthode publique ou interne non triviale ;Effect.fnUntracedpour les helpers sans intérêt de trace
🧱 Choisir son constructeur de Layer
Layer.succeed | service déjà construit |
Layer.sync | construction synchrone paresseuse |
Layer.effect | acquisition effectful — le défaut |
Layer.effectContext | 1 acquisition → plusieurs services (stubs de test) |
Layer.unwrap | la config choisit/construit le layer |
Layer.fresh | acquisition isolée (rare, tests) |
🔌 Câblage runtime
Layer.provide(...)→ cache la dépendanceLayer.provideMerge(...)→ seulement si la dépendance doit rester exposéeLayer.mergeAll(...)→ layers indépendants exposés- Préfère des valeurs de layers plates, triées topologiquement
⚠️
provideMerge / mergeAll ne sont pas des outils « fais-le compiler ».♾️ Travail longue durée
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const events = yield* Events.Service
yield* events.stream.pipe(
Stream.runForEach(handleEvent),
Effect.forkScoped, // ← fork dans le scope du layer
)
}),
)
- L'acquisition du layer doit se terminer — jamais de boucle infinie inline
Effect.forkScoped,FiberSetouFiberMap- Pas de méthode publique
startsauf besoin domaine explicite
🎁 Effect.fn avec transforms
const readAttachment = Effect.fn("Attachment.read")(
function* (ref: AttachmentRef) {
return yield* api.read(ref)
},
// wrapper qui voit les arguments d'origine :
(effect, ref) => effect.pipe(
attachmentError("Attachment.read", { attachmentId: ref.id }),
),
)
- Bons transforms : classification d'erreur, logs, spans, retry, timeout, cleanup
- 1 ou 2 transforms max — le générateur reste focalisé sur le workflow
⚙️ Config CONFIG.md
Jamais de process.env dans la logique applicative.
📖 Les recettes
const apiKey = yield* Config.redacted("API_KEY")
const model = yield* Config.option(Config.string("MODEL"))
const enabled = yield* Config.boolean("FEATURE_ENABLED")
.pipe(Config.withDefault(false))
const dataDir = yield* Config.schema(AbsolutePath, "APP_DATA_DIR")
Config.redacted→ credentials, toujoursConfig.option→ absence sémantiqueConfig.withDefault→ donnée manquante seulement ; une valeur malformée échoue quand même
🌍 Providers
- Défaut :
ConfigProvider.fromEnv() - Tests :
ConfigProvider.fromUnknown(...)(déterministe) - Remplacer :
ConfigProvider.layer(provider) - Fallback :
ConfigProvider.layerAdd(provider)(+{ asPrimary: true }pour surcharger) constantCase→ camelCase litSCREAMING_SNAKEnested(...)→ préfixe de scope
💡
.env et env vars = sources de démarrage/frontière, pas des lectures en plein workflow métier.🎛️ Le duo layer / layerConfig
export const layerConfig = (config: Config.Wrap<ClientOptions>) =>
Layer.effect(Client.Service,
Config.unwrap(config).pipe(
Effect.flatMap(makeClient),
Effect.map((client) => Client.Service.of(client)),
))
- Expose
layer(options)concret +layerConfig(Config.Wrap<Options>)pour le runtime - Test simple :
Layer.succeed(AppConfiguration.Service, testConfig)si le décodage Config n'est pas ce qu'on teste
⏰ Scheduling & Retry SCHEDULING.md
Fini les while (true) + sleep. Schedule partout.
🧠 Les règles à retenir
Effect.retry→ rejoue les échecs typés (pas les defects/interruptions)Effect.repeat→ répète les succès ; un échec stoppe- L'effet source tourne 1 fois avant le schedule
Schedule.recurs(3)= 3 reprises après le run initialspaced= pause après le travail ·fixed= cadence alignée- Backoff :
exponential/fibonacci+jittered(anti-tempête) +upTo({ times }) - Fallback en fin de retries :
Effect.retryOrElse(...)
🎯 Retry uniquement à la frontière la plus étroite, avec idempotence prouvée.
🔁 Worker de polling
const pass = runPass().pipe(
Effect.tapError((e) => Effect.logError("Worker.pass_failed", e)),
Effect.ignore, // échecs attendus : loggés, on continue
)
const run = pass.pipe(
Effect.repeat(Schedule.spaced("1 second")),
)
- Échecs typés gérés dans la passe, avant le repeat
- Les defects continuent de defecter → supervision
- Récupération de cause seulement aux frontières de supervision
📐 Politique de retry réutilisable
const retrySchedule = Schedule.exponential("100 millis").pipe(
Schedule.jittered,
Schedule.upTo({ times: 5 }),
)
reconcile(target).pipe(
Effect.retryOrElse(
retrySchedule.pipe(Schedule.tapInput((e) => logRetry(e))),
(e) => Effect.logError("reconcile.stopped", e),
),
)
- Rate-limit provider :
Schedule.passthrough+Schedule.modifyDelay→ max(backoff,retryAfterMs)
⏳ Timeouts & délais
Effect.timeout(...)→ vraie deadlineEffect.delay(...)→ démarrer plus tardEffect.sleep(...)en prod → seulement si dormir est le comportement métier- Batch : échecs par item isolés avec
tapError+ignoreautour de chaque item (Effect.forEach(..., { concurrency })) - En test :
TestClock, jamais de vrai temps
💾 Cache & mémoïsation CACHING.md
Stop aux Map + timestamp + boucle de prune maison.
🗝️ effect/Cache
Cache.make({ capacity, lookup, timeToLive })→ TTL fixe pour toutCache.makeWith(lookup, { capacity, timeToLive(exit, key) })→ TTL par entrée selon l'Exit- Dédup intégrée : les
getconcurrents d'une clé manquante partagent 1 lookup capacityobligatoire → borne le cache, plus de prune manuelinvalidate/refreshpour la staleness explicite- Ressources à cleanup (connexions) →
ScopedCache
🎯 TTL selon l'Exit
const cache = yield* Cache.makeWith(
(ref: string) => resolveUncached(ref),
{
capacity: 300,
timeToLive: (exit) =>
Exit.isSuccess(exit) && exit.value.cacheable
? "10 minutes"
: Duration.zero, // ← ne cache pas le dégradé
},
)
- TTL zéro → échecs transitoires non cachés, sans faire échouer l'appelant
- Negative-cache court possible pour les échecs stables (not-found)
🧭 Quel outil ?
| Même clé répétée dans le temps | Cache |
| Même clé en rafale concurrente | Cache (lookup partagé) |
| N clés distinctes + endpoint batch réel | Effect.request + RequestResolver |
| N clés distinctes, endpoint unitaire | Effect.forEach(..., { concurrency }) ± Cache |
| Une seule valeur, pas de clé | Effect.cached / cachedWithTTL |
⚠️ Pièges classiques
- Cache construit par appel → il ne cache rien. Construis-le 1 fois dans le layer
- Acquisition de client scopée dans le lookup → acquiers dans le layer (
Layer.build), le lookup devient un simple appel RequestResolversur une API REST unitaire → n'apporte rien, c'est une boucle déguisée
🌊 Streams STREAMS.md
Stream<A, E, R> : source pull-based, backpressurée — la consommation contrôle la demande.
🚰 Choisir sa source
| Valeurs en mémoire | Stream.make / fromIterable |
| Frontière callback → queue | Queue + Stream.fromQueue |
| Broadcast (tous les abonnés) | PubSub + Stream.fromPubSub |
| Valeur courante + changements | SubscriptionRef |
| API paginée | Stream.paginate (step déjà effectful) |
| Ticks planifiés avec valeurs | Stream.fromSchedule |
| AsyncIterable plateforme | Stream.fromAsyncIterable |
| Stream après lecture de services | Stream.unwrap |
🚫 Pas de stream juste pour boucler : effet répété sans valeurs =
Effect.repeat + Schedule.🔧 Transformer & consommer
| Pur / effectful | map / mapEffect |
| Concurrence bornée | mapEffect(fn, { concurrency }) (+ unordered: true si l'ordre est indifférent) |
| 1 → 0..n sorties | flatMap |
| Avec état | mapAccum / mapAccumEffect |
| Consommateur à effets | runForEach |
| Faire tourner sans les valeurs | runDrain |
| Tests / flux finis | take(n) + runCollect |
⚠️ Jamais de
runCollect sur un flux de prod non borné.🏠 Consommateur longue durée
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const gateway = yield* Gateway.Service
yield* gateway.events.pipe(
Stream.filter(isMessageEvent),
Stream.runForEach(handleEvent),
Effect.forkScoped,
)
}),
)
- Le layer possède la durée de vie du stream
- Interface de service : expose des
Stream, gardeQueue/Refprivés - Travail par clé (session, channel) → helper nommé sur
FiberMap, pas des maps de fibers ad hoc
🛗 Backpressure & buffers
- D'abord la backpressure naturelle ;
Stream.bufferseulement pour découpler producteur/consommateur "suspend"= backpressure ·"dropping"= jette le nouveau ·"sliding"= garde le plus récentStream.debounce→ période calme ·Stream.throttle→ débit- Erreurs :
Stream.mapErroraux frontières,catchTag/catchIftypés,catchCauseréservé à la supervision
🌐 HTTP Clients HTTP_CLIENTS.md
Modules effect/unstable/http/* : HttpClient, HttpClientRequest, HttpClientResponse.
📮 L'adaptateur possède toute la frontière
- Construire la requête → auth/headers → exécuter → classifier le status → décoder le body → mapper en erreurs domaine typées → retry si idempotent
HttpClientRequest.prependUrl(base URL),bearerToken,acceptJson,schemaBodyJsonHttpClient.filterStatusOkavant de décoderHttpClientResponse.schemaBodyJson(body) /schemaJson(status+headers+body)- Handlers HTTP entrants fins : décoder, appeler les services, mapper les erreurs — la logique métier vit dans les services
🔄 Retry & rate limit
HttpClient.retryTransient(...)→ transport, timeout, 408, 429, 500, 502, 503, 504HttpClient.withRateLimiter(...)→ pacing proactif, apprend des headersRetry-After(retry 429 par défaut)- Retry métier (payloads provider, idempotence) →
Effect.retryau niveau opération (cf. ⏰) - Appels provider/réseau hors des transactions DB
🩹 L'exception raw fetch (si vraiment nécessaire)
const request = Effect.fn("Provider.request")(function* (input: RequestInput) {
const response = yield* Effect.tryPromise({
try: (signal) => fetch(input.url, { signal }), // ← branche l'AbortSignal !
catch: (cause) => new ProviderError({ operation: "Provider.request", cause }),
})
if (!response.ok) return yield* Effect.fail(new ProviderRejected({ status: response.status }))
const json = yield* Effect.tryPromise({ try: () => response.json(), catch: toProviderError })
return yield* Schema.decodeUnknownEffect(ResponseSchema)(json)
})
- Légitime pour : transports plateforme, contraintes browser/edge, libs qui évitent les APIs unstable
- Toujours dans un service adaptateur, avec la même discipline de frontière
🧪 Tests TESTING.md
Déterministe > sleep. Toujours.
🏁 Les défauts
it.effect("finds a user", () =>
Effect.gen(function* () {
const users = yield* UserRepo.Service
const result = yield* users.find(UserId.make("u1"))
expect(Option.isSome(result)).toBe(true)
}).pipe(Effect.provide(UserRepo.testLayer)),
)
it.effectpar défaut ;it.liveseulement si le temps réel est le sujet du testTestClock.setTime/adjustpour sleeps, schedules, retries, timeouts- Fork les effets dormants avant d'avancer la TestClock
🤝 Synchronisation, pas de sleep
Deferred | signal one-shot (prêt / terminé) |
Queue | passer du travail / des événements observés entre fibers |
Latch | porte ouvrir/fermer réutilisable |
Ref | état d'observation partagé |
yield* runWorker.pipe(Effect.forkScoped)
yield* Deferred.await(ready) // attend le signal…
const msg = yield* Queue.take(published) // …pas une durée
🎭 Stubs de test first-class (double tag)
export interface TestInterface extends Interface {
readonly sentMessages: () => Effect.Effect<ReadonlyArray<Message>>
readonly failNextSend: (error: SendError) => Effect.Effect<void>
}
export const testLayer = Layer.effectContext(Effect.gen(function* () {
const service = TestService.of({ /* même objet pour les 2 tags */ })
return Context.empty().pipe(
Context.add(Service, service), // la prod dépend de celui-ci
Context.add(TestService, service), // le test contrôle via celui-là
)
}))
- Le même objet derrière les deux tags ; la prod ne connaît que le vrai tag
Layer.succeedpour les fakes statiques ultra-simples ;Layer.mockpour les mocks partiels dont les membres omis doivent échouer bruyamment- Config en test :
ConfigProvider.fromUnknown(...)si on teste le décodage, sinonLayer.succeed(AppConfiguration.Service, config)
☠️ Les interdits absolus
Si tu es en train de faire ça, arrête-toi.
as any,!, casts non vérifiés pour faire taire le typage EffectSchema.Class/Schema.TaggedClasscomme modèle de données par défaut- Classes d'erreur
_tagà la main quandSchema.TaggedErrorClassconvient - Récupération au niveau cause quand l'erreur typée suffit
Layer.mergeAll/provideMergeen mode « fais-le compiler »
- Autorité, credentials, persistance ou transports cachés derrière des défauts
Context.Reference Effect.sleep(...)arbitraire en test quand une primitive déterministe existe- Caches Map/TTL/prune ou dédup in-flight maison quand
effect/Cacheconvient - Retry sans idempotence prouvée
- Avaler les échecs épuisés sans fallback véridique à la frontière