effx
Introduction

What is effx

effx is the enterprise on-ramp to full-stack Effect. You write a familiar declarative surface, the compiler writes ordinary, inspectable Effect, and nothing locks you in.

effx bridges traditional enterprise frameworks and full-stack Effect. The author writes a familiar declarative surface: decorators or builder chains for routes, validation, errors and access. The compiler reads that source ahead of time and writes ordinary Effect. You keep Effect's guarantees, and you can stop using effx without rewriting the result.

Source: ADR 0012, AGENTS.md, docs/specs/0001-compiler-kernel.md.

The problem

A team that runs on a controller-and-decorator framework already knows the declarative surface: routes, DTO validation, guards, error responses, generated clients. Full-stack Effect gives stronger guarantees: typed errors (E), typed requirements (R), explicit layers and one schema language. It also asks the team to learn a new vocabulary before it ships anything. That cost makes the switch hard to start and hard to pitch (ADR 0012, "Context").

What effx is in that frame

effx puts the familiar surface on top and keeps ordinary Effect underneath. There are three layers, and you can read and replace the lower two.

LayerWhat you seeWhat exists underneath
Declaration@Query, @Command, @Http.Patch, @Http.Access, or the builder chainannotations that contribute to the IR (ADR 0001)
Generatedreadable files in .effx/generatedHttpApi, Rpc, CLI Command, a client, Foldkit commands
Applicationhandlers, services, layersnative Effect, written and owned by you

Source: ADR 0012 layer table; generators in packages/compiler/src/generate/.

The rest of this page shows one operation three ways, maps each annotation to the Effect construct it becomes, and states what stays yours and what effx does not do.

One operation, three views

The operation is "change a user's email": PATCH /users/:id/email, with a validated body, a typed failure for a missing user or a taken email, and an access check.

1. A NestJS controller method

The snippet below is written for this page after the patterns in the NestJS documentation. It is not copied from NestJS. Each construct links to the official page that defines it.

illustrative NestJS code
// DTO validated by class-validator (ValidationPipe is registered with app.useGlobalPipes)
export class ChangeEmailDto {
  @IsEmail()
  email: string;
}

// Guard: canActivate returns a boolean (a false result becomes a ForbiddenException)
@Injectable()
export class ChangeEmailGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    return request.user?.id === request.params.id;
  }
}

@Controller("users")
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Patch(":id/email")
  @UseGuards(ChangeEmailGuard)
  async changeEmail(@Param("id") id: string, @Body() dto: ChangeEmailDto) {
    const user = await this.users.setEmail(id, dto.email);

    if (user === undefined) throw new NotFoundException(`User ${id} not found`);

    return user;
  }
}
NestJS constructWhat the official documentation saysPage
@Controller("users"), @Patch(":id/email")A controller prefix and a method decorator form the route. @Patch() is one of the provided HTTP method decorators.Controllers
@Body() dto: ChangeEmailDto with @IsEmail()ValidationPipe validates classes decorated with class-validator rules. A failing request gets a 400 Bad Request. Bind the pipe with app.useGlobalPipes(new ValidationPipe()).Validation
@UseGuards(ChangeEmailGuard)A guard implements CanActivate, decides whether a request reaches the handler, and can be method-scoped with @UseGuards(). When it returns false, the framework throws ForbiddenException.Guards
throw new NotFoundException(...)HttpException and its subclasses are handled by the built-in exceptions layer. The built-in filter sends the response; custom filters give full control.Exception filters
constructor(private readonly users: UsersService)The provider is injected by type through the constructor and resolved by the Nest container.Providers

Two properties matter for the comparison. First, a TypeScript signature does not record which exceptions a function throws, so the Promise<User> return type above says nothing about NotFoundException or a ConflictException thrown inside UsersService. Second, the dependency is resolved at runtime by the container.

2. The effx declaration

This is the real declaration of User.ChangeEmail in the users example. The decorators record annotations. The method body is an ordinary Effect handler.

examples/users/src/operations.ts
import { Authorize, Cli, Command, Errors, Http, Query, Requirements, Rpc } from "@effx/runtime";
import { Effect } from "effect";
import { ChangeEmail, Read } from "./capabilities.ts";
import { ChangeEmailInput, EmailTaken, GetUserInput, UserNotFound } from "./schemas.ts";
import { Users } from "./services.ts";
import { User } from "./user.ts";

export class UserOperations {
  @Query({ name: "User.Get", input: GetUserInput, success: User.Public })
  @Http.Get("/users/:id")
  @Rpc("User.Get")
  @Cli("users get")
  @Authorize(Read)
  static get(input: typeof GetUserInput.Type) {
    return Effect.gen(function* () {
      const users = yield* Users;
      const user = yield* users.find(input.id);

      return { id: user.id, displayName: user.displayName };
    });
  }

  @Command({ name: "User.ChangeEmail", input: ChangeEmailInput, success: User.Self })
  @Http.Patch("/users/:id/email")
  @Rpc("User.ChangeEmail")
  @Cli("users change-email")
  @Authorize(ChangeEmail)
  @Errors(UserNotFound, EmailTaken)
  @Requirements(Users)
  static changeEmail(input: typeof ChangeEmailInput.Type) {
    return Effect.gen(function* () {
      const users = yield* Users;

      return yield* users.setEmail(input.id, input.email);
    });
  }
}

Source: examples/users/src/operations.ts. User.Get is the first operation in the file; User.ChangeEmail starts at @Command.

You wroteWhat it states
@Command({ name, input: ChangeEmailInput, success: User.Self })the operation kind, its input Schema and its success Schema. ChangeEmailInput is Schema.Struct({ id: UserId, email: Email }) with Email = Schema.NonEmptyString (examples/users/src/schemas.ts).
@Http.Patch("/users/:id/email")the HTTP exposure
@Rpc(...), @Cli(...)two more exposures of the same operation
@Errors(UserNotFound, EmailTaken)an assertion: the handler's inferred E must equal this set (spec 0002, ADR 0005, diagnostics EFFX2201, EFFX2202)
@Requirements(Users)an assertion: the handler's inferred R must equal this set (ADR 0005, EFFX2302, EFFX2303)
@Authorize(ChangeEmail)a recorded capability fact. It is not an enforced guard (see below).

The users example does not enforce access. @Authorize(ChangeEmail) records a Capability node and an AuthorizedBy edge in the IR. effx has no authorization runtime; Cedar and leases are deferred (ADR 0007, docs/specs/0006-access-contract-extension.md). The equivalent of the NestJS guard in effx is @Http.Access, which this example does not use, so effx build prints an EFFX2504 warning for each unprotected HTTP operation (STATE.md, client/Foldkit evidence). See Guards in generated code.

3. The generated Effect HttpApi endpoint

effx build writes this for the same operation. The excerpt is from examples/users/.effx/generated/http.ts, as the compiler wrote it (indentation included). That directory is gitignored, so the file exists only after a build (.gitignore; see Getting started).

examples/users/.effx/generated/http.ts (excerpt)
export class HttpOperations extends HttpApiGroup.make("operations").add(
  HttpApiEndpoint.patch("User.ChangeEmail", "/users/:id/email", {
  params: { id: Schema.String },
  payload: ChangeEmailInput,
  success: User.Self,
  error: Schema.Union([EmailTaken, UserNotFound]),
}),
  // HttpApiEndpoint.get("User.Get", ...) omitted
) {}

export class Api extends HttpApi.make("effx").add(HttpOperations) {}

export const ApiHandlers = HttpApiBuilder.group(
  Api,
  "operations",
  Effect.fn(function* (handlers) {
    return handlers.handleAll({
      "User.ChangeEmail": ({ params, payload }) => UserOperations.changeEmail({ ...params, ...payload }),
      // "User.Get" omitted
    });
  }),
);

ChangeEmailInput, EmailTaken, UserNotFound and User are imported from your own files, not copied. The handler calls your UserOperations.changeEmail. The file imports effect modules and your source, and nothing from @effx/* (see the import block in examples/users/.effx/generated/http.ts).

The three views side by side

ConcernNestJSeffx declarationGenerated Effect
Route@Controller("users") + @Patch(":id/email")@Http.Patch("/users/:id/email")HttpApiEndpoint.patch("User.ChangeEmail", "/users/:id/email", ...)
Input and validationDTO class with class-validator decorators, checked by ValidationPipeinput: ChangeEmailInput (an Effect Schema)params: { id: Schema.String }, payload: ChangeEmailInput
Failurethrow an HttpException subclass; not in the type@Errors(UserNotFound, EmailTaken), checked against the handler's Eerror: Schema.Union([EmailTaken, UserNotFound])
Serviceconstructor injection resolved by the container@Requirements(Users), checked against the handler's Rnone; you provide Users.layer (examples/users/src/server-main.ts)
Access@UseGuards(...)@Http.Access(...) (not used in this example)guards.ts types and an authorize thunk
Handlerthe controller methodthe decorated static methodhandleAll({ "User.ChangeEmail": ... }) calls it

Guards in generated code

For an operation that has @Http.Access with a credential other than None, the compiler adds a guard slot. The generated guards.ts is a type, not an implementation. This is from the Profile fixture, which does use @Http.Access:

packages/frontend-ts/test/fixtures/users/.effx/client-foldkit-generated/guards.ts
// Generated by effx. Do not edit.
// Source: ../../src/operations.client-foldkit#ClientFoldkitProfileOperations.read, ../../src/operations.client-foldkit#ClientFoldkitProfileOperations.update
// IR: operation:Profile.Read, operation:Profile.Update

import { Effect } from "effect";
import { HttpServerRequest } from "effect/http";
import { ClientFoldkitProfileOperations } from "../../src/operations.client-foldkit.ts";

type GuardEffect<Callback> = Callback extends () => Effect.Effect<infer Principal, infer Problem, infer Requirements>
  ? Effect.Effect<Principal, Problem, Requirements> : never;

export type ProfileGuards = {
  readonly "read": (request: HttpServerRequest.HttpServerRequest) => GuardEffect<Parameters<typeof ClientFoldkitProfileOperations.read>[1]>;
  readonly "update": (request: HttpServerRequest.HttpServerRequest) => GuardEffect<Parameters<typeof ClientFoldkitProfileOperations.update>[1]>;
};

export type AppGuards = {
  readonly "effx/profile": ProfileGuards;
};

The generated handler passes () => guards["update"](request) to your handler as its authorize argument. Your handler decides when to call it. effx never runs it. Missing or non-Effect guard callbacks fail TypeScript checking.

Source: packages/compiler/src/generate/guards.ts, packages/compiler/src/generate/http.ts (handlerLine), docs/specs/0006-access-contract-extension.md §"HTTP lowering and guard interface". Details: Problems and access.

Annotation to Effect construct

Each annotation maps to a named Effect construct, or is recorded in the IR only. Rows are supported: the generator or extension named in the Source column implements them. Where the construct column says "none", the annotation generates no Effect code today. Constructs come from the effect package at the paths the generators import.

effx annotationEffect construct it becomesGenerated fileSource
@Query, @Command (name, input, success)Schema values used as the endpoint payload/success and the Rpc payload/successhttp.ts, rpc.tsgenerate/http.ts, generate/rpc.ts, extensions/core.ts
@Http.Get ... @Http.DeleteHttpApiEndpoint.get/post/put/patch/delete, collected in HttpApiGroup.make(group), then HttpApi.make(root). Handlers: HttpApiBuilder.group(...) with handleAll. Wiring: HttpApiBuilder.layer(Api) and HttpRouter.serve, exported as AppRoutes.http.tsgenerate/http.ts (endpointExpr, groupLines, rootLines, handlersLines)
@Http.GroupHttpApiGroup.make(group) plus OpenApi.annotations merged with annotateMerge. Group defaults are expanded at compile time and never serialized.http.ts, <group>-contract.tsgenerate/http.ts (groupAnnotation), group-defaults.ts, spec 0013
@Http.Contractendpoint options params, query, headers, payload, success; HttpApiSchema wrappers for media type; .middleware(Marker) with your HttpApiMiddleware.Service classes; OpenApi.annotationshttp.tsgenerate/http.ts (endpointExpr), spec 0005
@Http.Problemsthe endpoint error schemas, produced by your registry functionhttp.tsgenerate/http.ts (problemErrors), spec 0005
@Http.Access.annotateMerge(yourAnnotator({...})) on the endpoint, plus the authorize thunk and the AppGuards typehttp.ts, guards.tsgenerate/http.ts, generate/guards.ts, spec 0006
@Errorsthe endpoint error and Rpc.make error as Schema.Union([...]). The set is asserted against the handler's inferred E.http.ts, rpc.tsextensions/core.ts, ADR 0005
@Requirementsno generated construct. It asserts the handler's inferred R. R stays in the handler's type and in the Layer you provide.noneextensions/core.ts, ADR 0005
@RpcRpc.make, RpcGroup.make, group.toLayer, RpcServer.layerHttp, RpcClient.makerpc.tsgenerate/rpc.ts
@CliCommand.make, Command.withSubcommands, Flag.String(...) with Flag.withSchema(Schema.fromJsonString(...)), Command.run (from effect/cli)cli.tsgenerate/cli.ts
client (every HTTP operation; no annotation)HttpApiClient.make(Api), a Context.Service class Client with Layer constructors (layer, layerWith, layerAt)client.tsgenerate/client.ts, spec 0007
@Foldkit.CommandFoldkit Command.define adapters over client.tsfoldkit.tsgenerate/foldkit.ts, spec 0007
@Authorize, @PersistentModelnone. Recorded as Capability, Focus and Model nodes in the IR only.noneextensions/core.ts; ADR 0007 (deferred); spec 0008 (proposed)

The last row is the one place where an annotation has no Effect construct today. ADR 0012 says an annotation with no such mapping is not added. These two annotations exist from earlier decisions (ADR 0007, spec 0008) and are listed here as recorded facts, not as generated behaviour. Their status: Persistence and authority.

What stays yours

You keepHow
HandlersThe operation method is your Effect. The compiler never rewrites handler bodies (ADR 0001). Generated files call it.
Services and layersYou write Context.Service classes and Layers and provide them yourself, for example AppRoutes.pipe(Layer.provide(Users.layer), ...) (examples/users/src/server-main.ts).
E and RBoth stay in the handler's type. effx infers them from the handler and diagnoses a mismatch with @Errors or @Requirements (ADR 0005). Nothing is hidden behind exceptions or ambient context.
Raw EffectYou can mix declared operations with hand-written HttpApi groups and handlers in one program (ADR 0012, "Consequences"). A declaration-only group binds raw handlers (.declare(), spec 0010; see External bindings).
The way outThe generated files are the contract. Remove .effx/ from .gitignore, commit .effx/generated, and stop running effx build. See Generated code.

What effx does not do

Not in effxWhy, and where to look
An effx runtime that executes requestsGenerated code is ordinary Effect with no effx runtime (ADR 0008).
Hidden dependency injectionNo container, registry or reflection (ADR 0008). Requirements stay visible in types and Layers (ADR 0005, ADR 0012).
Hiding E or RADR 0012, "Rules out".
Evaluating access@Http.Access and @Authorize are declarations. effx never executes an access value, resolves a credential or chooses a principal (spec 0006). Cedar authorization and leases are deferred (ADR 0007).
Persistence or SQLGap. Spec 0008 is proposed, not frozen. STATE.md parks it. examples/users uses an in-memory Ref and HashMap. SQL itself comes from the Effect packages (packages/effect/src/sql/ and packages/sql/* in the Effect repository); effx generates nothing for it.
Jobs, queues, schedulingGap in effx: no spec or source. Effect has Schedule, Queue, workflow and cluster modules (packages/effect/src/{Schedule,Queue}.ts, packages/effect/src/{workflow,cluster}/); effx does not generate code for them.
Scaffolding, generators, a test harnessGap. The CLI has four commands: check, build, inspect, graph (scripts/effx.ts, CLI).
Reimplement the Effect ecosystemAuthentication, persistence, jobs, caching and infrastructure come from existing Effect packages and your layers. Where none exists, the documentation says "gap" (ADR 0012).

Effect paths above are in the official repository at commit b5a2d4c1d6, the clone used to write this page. Status words: supported is implemented, via ecosystem package is done by an Effect package, gap is nothing today.

How the compiler runs

 source        decorators  @Query @Http.Get ...      packages/runtime  (syntax only)
 (.ts)         builder     Operation.query(...)...
   │
   │  frontend: TypeScript compiler API              packages/frontend-ts
   ▼
 Collected     declarations + annotations (same shape from both syntaxes)
   │
   │  interpret: Extension.interpreters              packages/compiler
   ▼
 IR            ApplicationIR = nodes + edges         packages/ir
   │            (normalized, Schema-defined)
   │  analyses: Analysis(ir, index) → Diagnostic[]   errors stop generation
   ▼
 generators    Generator(ir, index, ctx) → GeneratedFile[]
   │
   ▼
 .effx/generated/*.ts   ordinary Effect
 .effx/ir.json          canonical IR
 .effx/manifest.json    hash, locations, diagnostics
StageWhat happensDefined in
SourceDecorators and builder chains are source syntax. Both lower to identical Annotations.packages/runtime/src, packages/frontend-ts/src
CollectThe frontend returns Collected: declarations, their annotations, frontend diagnostics.packages/compiler/src/Collected.ts
InterpretEach annotation name is owned by an extension interpreter. It returns a Contribution of nodes, edges and diagnostics.packages/compiler/src/pipeline.ts
NormalizeContributions merge into one ApplicationIR with a stable order.packages/ir/src/normalize.ts
AnalyzeAnalyses read the IR and its graph index and return diagnostics.packages/compiler/src/pipeline.ts
GenerateIf no diagnostic has severity error, generators emit files. Output is sorted by path.packages/compiler/src/pipeline.ts

The packages are @effx/ir, @effx/compiler, @effx/frontend-ts, @effx/runtime and @effx/cli (AGENTS.md package map). The vertical slice examples/users measured 70 hand-written declaration lines against 118 generated projection lines for two operations (STATE.md, "CLI/users vertical slice" evidence).

The packages are not published to a registry. You run effx from the monorepo today. See Getting started.

On this page