Shipping Effect v4
Notes from cutting over the template to effect-smol — what changed, what surprised us, and what we'd do again.
The template just landed on Effect v4 (effect-smol). Three things felt
different from day one: services lost the .Default layer, models split
Generated into two explicit variants, and HttpApi got a stable
v4 surface.
Services use Context.Service
There is no Effect.Service in v4. The published package exports
Context.Service only. Every service module pairs its tag with a
Layer.effect(Tag, make) — no static .Default, no static .Test.
Use packages/server/src/Todos/ as the living reference for service
shape, layer composition, branded IDs, and tenant scoping. Every new
resource should mirror its layout.
A concrete example
import { Context, Effect, Layer } from "effect"
export class Greeter extends Context.Service<Greeter, {
readonly hello: (name: string) => Effect.Effect<string>
}>()("app/Greeter") {}
export const make = Effect.gen(function*() {
return Greeter.of({
hello: (name) =>
Effect.succeed(`hello, ${name}`).pipe(
Effect.withSpan("Greeter.hello", { attributes: { name } }),
),
})
})
export const GreeterLive = Layer.effect(Greeter, make)
Models pick an explicit generator
Model.GeneratedByDb is for DB-assigned values (serial IDs, default
timestamps). Model.GeneratedByApp is for application-generated values
(crypto.randomUUID()). The old Model.Generated is gone.
HttpApi stabilised
HttpApi.make + HttpApiBuilder.group + HttpApiEndpoint.{get,post,delete}
are the v4 surface. Note HttpApiEndpoint.delete — not .del. Endpoints
declare success + failure schemas; the client is derived end-to-end.
What we’d do again
Cut over in one pass. Trying to live in both v3 and v4 simultaneously costs more than the rewrite. The template is small enough that a single focused effort lands cleanly.