Skip to content

2026-06-03

Effect on Lambda: A Link Shortener as a Pattern Showcase

A small, complete URL shortener on AWS Lambda and DynamoDB, built with Effect and SST v4, showing schema-at-the-boundary, layers, and tagged-error mapping in code small enough to hold in your head.

A typical TypeScript Lambda handler accretes cross-cutting work inline: JSON.parse(event.body), ad-hoc if (!body.url) checks, URL validation in a try/catch, then business logic wrapped in more try/catch blocks where each catch re-derives an HTTP status and re-serializes an error body. The non-obvious cost is not the boilerplate; it is that statuses and error shapes get chosen at the call site, so they drift. Worse, the only way to test the logic is to either hit real AWS or hand-mock the SDK client. Effect collapses that pile into a thin adapter over a typed program; this post walks one buildable artifact, a URL shortener on Lambda and DynamoDB wired with SST v4, to show exactly what each Effect primitive buys you. It is written for backend TypeScript engineers who are comfortable with the language but new to Effect.

Note: This is a build-and-local-verification report. The project was built and run locally (27 @effect/vitest tests, plus an end-to-end pass against DynamoDB Local), but it was not deployed to real AWS. There are no production latency, cost, or cold-start numbers here, and none are invented. The full source is public: ayhansipahi/effect-link-shortener.

If you want the conceptual primer first (what Effect promises, a 12-week adoption path), the companion post Learning Effect: A Practical Adoption Guide covers it. This post assumes that primer and stays focused on the finished artifact.

The handler as a thin adapter

The shape is always the same: parse the API Gateway event, hand the typed input to a program, run the program, map the result to a response. With that discipline, the exported handlers carry no logic. Here is the real src/handlers/create.ts:

import type { APIGatewayProxyEventV2 } from "aws-lambda"
import { runHandler } from "../lambda"
import { createProgram } from "../programs"

export const handler = (event: APIGatewayProxyEventV2) =>
  runHandler(createProgram(event.body, `https://${event.requestContext.domainName}`))

The handler does two things: pull the raw body and domain off the event, and pass them to a program. The redirect handler is the same two lines against event.pathParameters. Everything else, decoding, branching, error mapping, lives in the typed program and the single run seam.

The program itself reads top to bottom. From src/programs.ts:

export const createProgram = (body: string | null | undefined, baseUrl: string) =>
  decodeBody(body).pipe(
    Effect.flatMap(createLink),
    Effect.map((record) => json(201, presentCreated(record, baseUrl))),
  )

Three steps, each with a clear type: decodeBody turns an untrusted string into a typed request, createLink is the business logic, and json(201, ...) presents the result. There is no try/catch and no status chosen mid-function. The next sections unpack each piece.

APIGW event

handler (2 lines)

program (Effect)

withErrorMapping

APIGW response

Schema decode

Layer provision

Schema at the boundary

effect/Schema decodes untrusted input once, at the edge. Inside the program, every value is already typed and validated. The repo defines the boundary types in src/domain/schema.ts:

import { Schema } from "effect"

export const ShortCode = Schema.String.pipe(
  Schema.pattern(/^[0-9A-Za-z_-]{4,32}$/),
  Schema.brand("ShortCode"),
)
export type ShortCode = typeof ShortCode.Type

export const Url = Schema.String.pipe(
  Schema.maxLength(2048),
  Schema.filter((s) => /^https?:\/\//.test(s) || "must be an http(s) URL"),
)

export const CreateLinkRequest = Schema.Struct({
  url: Url,
  customCode: Schema.optional(ShortCode),
  expiresIn: Schema.optional(Schema.Number.pipe(Schema.int(), Schema.positive())),
})

Two details matter. First, ShortCode is a branded string. A raw string cannot be passed where a ShortCode is required; the compiler rejects it. You construct one through the schema, so a value carrying the brand has actually passed the pattern check. In tests, ShortCode.make("promo") builds one.

Second, the LinkRecord schema is the single place the DynamoDB item shape is defined, including the default for clicks:

export const LinkRecord = Schema.Struct({
  shortCode: ShortCode,
  url: Url,
  createdAt: Schema.Number,
  expiresAt: Schema.optional(Schema.Number),
  clicks: Schema.optionalWith(Schema.Number, { default: () => 0 }),
})

Decode failures do not scatter as if checks. They become a single ParseError that the boundary maps once. In src/http.ts, decodeBody parses JSON, decodes against CreateLinkRequest, and converts any ParseError into a tagged InvalidRequest carrying formatted issues:

export const decodeBody = (body: string | null | undefined) =>
  Effect.try({
    try: () => (body ? JSON.parse(body) : {}),
    catch: () => new InvalidRequest({ issues: "request body is not valid JSON" }),
  }).pipe(
    Effect.flatMap((raw) => Schema.decodeUnknown(CreateLinkRequest)(raw)),
    Effect.catchTag(
      "ParseError",
      (error) =>
        new InvalidRequest({ issues: ParseResult.ArrayFormatter.formatErrorSync(error) }),
    ),
  )

Services behind layers

Dependencies are declared as Context.Tags and provided by Layers. The program states what it needs; the wiring is supplied from the outside and is swappable. The storage port is a tag with three methods, from src/services/LinkStore.ts:

export class LinkStore extends Context.Tag("app/LinkStore")<
  LinkStore,
  LinkStoreService
>() {}

The implementation lives in a factory, makeLinkStoreLayer, that takes a DynamoDB document layer plus a table name. Because the implementation is parameterized this way, the same factory builds both the production and the local layers. Production reads the table name from SST’s linked resource:

export const LinkStoreLive = makeLinkStoreLayer(
  DynamoDBDocument.layer({ marshallOptions: { removeUndefinedValues: true } }),
  Resource.Links.name,
)

The local layer points the same @effect-aws/dynamodb document client at DynamoDB Local through a custom endpoint:

const localDocumentLayer = DynamoDBDocument.baseLayer((_defaultConfig) =>
  DynamoDBDocumentClient.from(
    new DynamoDBClient({
      endpoint,
      region: "local",
      credentials: { accessKeyId: "local", secretAccessKey: "local" },
    }),
    { marshallOptions: { removeUndefinedValues: true } },
  ),
)

export const LinkStoreLocal = makeLinkStoreLayer(localDocumentLayer, tableName)

One factory, two layers, one program that does not change between them. The Effect docs steer Context.Tag toward cases where no single default fits and the implementation should be chosen per environment, while Effect.Service suits app code with one clear default. The store here is a textbook tag case: production, local, and tests each provide their own implementation. By contrast, CodeGen (the short-code generator) does have a clear default, so it uses Effect.Service with an in-class Default layer.

Tagged errors map to one status table

Domain failures are Data.TaggedError classes, defined once in src/domain/errors.ts:

export class InvalidRequest extends Data.TaggedError("InvalidRequest")<{
  readonly issues: unknown
}> {}
export class ShortCodeTaken extends Data.TaggedError("ShortCodeTaken")<{
  readonly shortCode: string
}> {}
export class ShortCodeNotFound extends Data.TaggedError("ShortCodeNotFound")<{
  readonly shortCode: string
}> {}
export class LinkExpired extends Data.TaggedError("LinkExpired")<{
  readonly shortCode: string
}> {}
export class StoreUnavailable extends Data.TaggedError("StoreUnavailable")<{
  readonly cause: unknown
}> {}

export type AppError =
  | InvalidRequest | ShortCodeTaken | ShortCodeNotFound | LinkExpired | StoreUnavailable

Because each error carries a _tag, the status mapping lives in exactly one place. withErrorMapping uses Effect.catchTags for the known set and Effect.catchAllDefect for anything unexpected, so the response shape is decided once:

export const withErrorMapping = <R>(
  program: Effect.Effect<APIGatewayProxyStructuredResultV2, AppError, R>,
): Effect.Effect<APIGatewayProxyStructuredResultV2, never, R> =>
  program.pipe(
    Effect.catchTags({
      InvalidRequest: (e) => Effect.succeed(json(400, { error: "InvalidRequest", issues: e.issues })),
      ShortCodeTaken: (e) => Effect.succeed(json(409, { error: "ShortCodeTaken", shortCode: e.shortCode })),
      ShortCodeNotFound: () => Effect.succeed(json(404, { error: "ShortCodeNotFound" })),
      LinkExpired: () => Effect.succeed(json(410, { error: "LinkExpired" })),
      StoreUnavailable: () => Effect.succeed(json(500, { error: "InternalError" })),
    }),
    Effect.catchAllDefect(() => Effect.succeed(json(500, { error: "InternalError" }))),
  )

Per the Effect docs, Effect.catchTags “handles multiple errors in a single block of code using their _tag field.” The return type is never in the error channel, which is the compiler proving that every AppError case has been handled. Adding a new failure mode is mechanical: declare one tagged error, add one row to this table. The trade-off is that this indirection only pays off once you have more than a couple of error cases; for a one-line S3-trigger Lambda it would be overkill.

One conditional put, two collision policies

The create path has two ways to choose a short code, and they need different collision behavior. The decision is which policy applies per outcome:

Yes

Collision

OK

No

Collision

Attempts exhausted

OK

CreateLinkRequest decoded

Custom code supplied?

Conditional put with that code

409 ShortCodeTaken (no retry)

201 Created

Generate code, conditional put

Regenerate, bounded retry

StoreUnavailable 5xx

Both branches use the same DynamoDB conditional put. The store layer puts an item with ConditionExpression: "attribute_not_exists(shortCode)". @effect-aws/dynamodb surfaces a failed condition as an error whose _tag is "ConditionalCheckFailedException", which the layer translates into the domain ShortCodeTaken:

Effect.catchAll((e): Effect.Effect<never, ShortCodeTaken | StoreUnavailable> =>
  e._tag === "ConditionalCheckFailedException"
    ? Effect.fail(new ShortCodeTaken({ shortCode: record.shortCode }))
    : Effect.fail(storeError(e)),
)

The policy difference lives in src/core/createLink.ts. If the caller supplied a custom code, a collision is a real 409 and the function fails hard. The user asked for that exact code; retrying would be wrong. If the code was generated, a collision means try a different one, bounded:

if (request.customCode !== undefined) {
  return yield* save(request.customCode)
}

const saveWithGeneratedCode = (
  triesLeft: number,
): Effect.Effect<LinkRecord, StoreUnavailable, CodeGen | LinkStore> =>
  codeGen.generate.pipe(
    Effect.flatMap(save),
    Effect.catchTag("ShortCodeTaken", (collision) =>
      triesLeft > 1
        ? saveWithGeneratedCode(triesLeft - 1)
        : new StoreUnavailable({ cause: collision }),
    ),
  )

return yield* saveWithGeneratedCode(5)

saveWithGeneratedCode is a plain recursive helper: generate, persist, and on ShortCodeTaken recurse with one fewer try. When the tries run out, the final collision becomes StoreUnavailable (a 5xx), because being unable to find a free code is a store problem, not a client mistake; the caller did nothing wrong, so a 409 would be the wrong signal. The recursion reads top to bottom and needs no extra combinator.

Why recursion, not Effect.retry

The project started on Effect.retry, then moved to the recursive helper. The honest reason is a narrowing trap, not a claim that recursion is always better. Effect.retry takes an until (stop) option rather than a while, so “retry only on ShortCodeTaken” has to be written inside-out as until: (e) => e._tag !== "ShortCodeTaken". That double negative is already easy to get backwards. Worse, a bare predicate is inferred as a Refinement, which narrows the error channel; the narrowed type then breaks the follow-up catchTag that is supposed to convert the exhausted case into StoreUnavailable. The recursive helper sidesteps both problems and keeps the exhaustion-to-StoreUnavailable conversion in plain sight. Effect.retry with Schedule is the right tool for genuinely transient, time-based retries; collision handling here is neither.

Layer-swap testing

The layer boundary that exists for production is the same boundary that makes tests trivial. Tests provide an in-memory LinkStore (a Map behind the same interface) and a deterministic CodeGen (a fixed queue of codes); there is no AWS and no network. @effect/vitest’s it.effect runs each test as an Effect with those layers provided. The collision-retry behavior, for example, is asserted with plain values:

it.effect("retries past generated-code collisions", () =>
  Effect.gen(function* () {
    const seed = [
      { shortCode: "AAAAAAA", url: "https://a.com", createdAt: 1, clicks: 0 },
      { shortCode: "BBBBBBB", url: "https://b.com", createdAt: 1, clicks: 0 },
    ]
    const rec = yield* createLink({ url: "https://x.com" }).pipe(
      Effect.provide(provide(["AAAAAAA", "BBBBBBB", "CCCCCCC"], seed)),
    )
    expect(rec.shortCode).toBe("CCCCCCC")
  }),
)

The first two generated codes collide with seeded rows; the third is free; the assertion is one expect. Dependency injection is not a separate test scaffold here. It is the same mechanism the production handler uses, pointed at different layers.

ManagedRuntime and a non-fatal click counter

The runtime is built once, at module scope, in src/lambda.ts:

const MainLayer = Layer.mergeAll(CodeGen.Default, LinkStoreLive)

export const runtime = ManagedRuntime.make(MainLayer)

export const runHandler = (
  program: Effect.Effect<APIGatewayProxyStructuredResultV2, AppError, LinkStore | CodeGen>,
): Promise<APIGatewayProxyStructuredResultV2> => runtime.runPromise(withErrorMapping(program))

ManagedRuntime.make turns a configuration layer into a runtime you can run effects against. Placing it at module scope is design intent: the runtime and its layers are built once per warm container and reused across invocations, rather than rebuilt per request. That is a structural choice, not a measured speedup; this project was not deployed, so there are no cold-start numbers to report.

The redirect path separates the must-succeed from the nice-to-have. The redirect itself must work; the click counter is analytics. In src/core/visit.ts, the increment is made non-fatal with Effect.orElseSucceed:

export const visit = (shortCode: ShortCode) =>
  Effect.gen(function* () {
    const store = yield* LinkStore
    const link = yield* resolveLink(shortCode)
    const clicks = yield* store.incrementClicks(shortCode).pipe(Effect.orElseSucceed(() => undefined))
    return { url: link.url, clicks }
  })

If the counter update fails, clicks falls back to undefined and the redirect still returns the URL. The local test confirms this: with a forced increment failure, result.url is still correct and result.clicks is undefined. The store also guards the update with attribute_exists(shortCode), so the counter only applies to a live item.

Wiring it with SST v4

One sst.config.ts declares the table and the routes. The TTL attribute is set on the table so DynamoDB itself expires links:

const table = new sst.aws.Dynamo("Links", {
  fields: { shortCode: "string" },
  primaryIndex: { hashKey: "shortCode" },
  ttl: "expiresAt",
})

const api = new sst.aws.ApiGatewayV2("Api")
api.route("POST /links", { handler: "src/handlers/create.handler", link: [table] })
api.route("GET /{code}", { handler: "src/handlers/redirect.handler", link: [table] })

The link: [table] array makes the table a linked resource, which is why import { Resource } from "sst" exposes Resource.Links.name at runtime in LinkStoreLive. There are two dev loops: sst dev provisions a live Lambda, while a local loop (local/server.ts plus DynamoDB Local via docker-compose.yml) wires the LinkStoreLocal layer for fast offline iteration. This is the layer swap again, this time at the deployment boundary rather than the test boundary.

Common pitfalls

A few sharp edges showed up while building this, all worth knowing before you copy the pattern.

vitest major versus the @effect/vitest peer. @effect/[email protected] declares peers of vitest ^3.2 and effect ^3.21. A vitest-4-capable @effect/vitest exists only as a 4.x beta that needs the Effect 4 beta. The repo’s package.json pins vitest ^3.2.0 alongside @effect/vitest ^0.29.0 and effect ^3.21.2, staying on vitest 3 rather than dragging the whole stack onto the Effect 4 beta. That choice carries a known dev-only, UI-only vitest advisory (the vitest --ui server); this project runs headless and never bundles vitest into Lambda, so the advisory is accepted deliberately. Check the npm peer ranges before you bump.

TestClock starts at epoch 0. Under it.effect, the TestClock “starts at 0, simulating the beginning of time” until you advance it. Expiry tests must seed timestamps relative to that; the repo’s tests use createdAt: 1. Assuming wall-clock Date.now() will make expiry assertions fail in confusing ways. Use the TestClock deliberately, or reach for it.live when you genuinely want real time.

DynamoDB Local reports ready before it is. docker compose up -d returns once the TCP port is open, but the Java process may not yet serve requests. A port check is not a readiness check. Poll a real request, such as a describe or list against the endpoint, before running the suite; the repo handles this in local/setup.ts.

Branded types do not help if you cast. A ShortCode brand only guards anything if you go through the schema (ShortCode.make(...) or Schema.decode). Writing value as ShortCode defeats the check at compile time and lets an unvalidated string through. Treat the as cast as a smell anywhere a branded type is involved.

When this pattern is worth it

The pattern, handler-as-thin-adapter over a typed Effect program, earns its keep when error handling and dependency wiring would otherwise sprawl: several failure modes that each need a distinct HTTP status, dependencies you want to swap per environment, and logic you want to test without touching AWS. Those are exactly the conditions where the classic try/catch handler drifts. Reach for the plain handler instead when the function is genuinely trivial, a one-line transform or a single S3 trigger, where Effect’s vocabulary (Tag, Layer, tagged errors) is upfront cost with little to offset it. Two honest caveats for adoption: Effect is a dependency with real learning-curve and surface-area cost, and @effect-aws/* is a community project, not an AWS-published SDK.

If you want to try the shape, the next concrete step is to clone the repo, run the 27 tests with no AWS account, and read src/programs.ts and src/http.ts together; those two files are where the thin-adapter idea becomes legible. The natural follow-up work, deploying it and measuring cold-start p50/p99 and per-request DynamoDB capacity, is exactly what this report does not cover yet.

References

Related posts

Learning Effect: A Practical Adoption Guide for TypeScript Developers

A comprehensive guide to understanding Effect, learning it incrementally, and integrating it with AWS Lambda. Includes real code examples, common pitfalls, and practical patterns from production usage.

typescripteffectaws-lambda+5
Five AWS Lambda Anti-Patterns TypeScript Developers Bring From Monoliths

DI containers, monolithic SDKs, god-handlers, top-level secret fetches, and heavy ORMs - what they cost on cold start, and the functional shape that replaces them.

aws-lambdatypescriptserverless+2
DynamoDB Rate Limiting: Strategies for Single Table Design at Scale

Practical strategies to prevent and handle DynamoDB throttling in Single Table Design applications. Covers partition key design, write sharding, capacity modes, DAX caching, retry patterns, and CloudWatch monitoring for high-throughput systems.

dynamodbawsrate-limiting+5
Running Bun and Alternative JavaScript Runtimes on AWS Lambda

Technical implementation guide for running Bun and Deno on AWS Lambda using custom runtimes, with real performance benchmarks, cost analysis, and production deployment patterns.

aws-lambdabundeno+4
AWS AppSync & GraphQL: Building Production-Ready Real-time APIs

A comprehensive guide to building scalable real-time APIs with AWS AppSync, covering JavaScript resolvers, subscription filtering, caching strategies, and infrastructure as code patterns.

awsappsyncgraphql+5