AWS Lambda'da Effect: SST ve DynamoDB ile URL Kısaltıcı Oluşturma
AWS Lambda ve DynamoDB üzerinde Effect ve SST v4 ile küçük, eksiksiz bir URL kısaltıcı; sınırda şema, layer'lar ve tagged error eşlemesini gösterir.
Tipik bir TypeScript Lambda handler’ı kesişen işleri kodun içine yığar: JSON.parse(event.body), gelişigüzel if (!body.url) kontrolleri, bir try/catch içinde URL doğrulaması, ardından her catch bloğunun bir HTTP statüsünü yeniden türetip bir hata gövdesini yeniden serileştirdiği iş mantığı. Asıl maliyet ilk bakışta görünmez; sorun boilerplate değil, statülerin ve hata şekillerinin çağrı noktasında seçilmesi ve bu yüzden zamanla kayması. Dahası, mantığı test etmenin tek yolu ya gerçek AWS’ye bağlanmak ya da SDK istemcisini elle mock’lamak. Effect bu yığını tipli bir programın üzerindeki ince bir adaptöre indirger. Kurulabilir tek bir parça, Lambda ve DynamoDB üzerinde SST v4 ile kurulmuş bir URL kısaltıcı, her Effect yapı taşının size tam olarak ne kazandırdığını gösterir. Dile hâkim ama Effect’e yeni olan backend TypeScript geliştiricileri için yazıldı.
Önce kavramsal girişi (Effect’in ne vaat ettiği, 12 haftalık bir benimseme yolu) isterseniz, eşlik eden yazı Effect Öğrenmek: Pratik Bir Benimseme Rehberi bunu kapsıyor.
İnce bir adaptör olarak handler
Şekil her zaman aynıdır: API Gateway olayını ayrıştır, tipli girdiyi bir programa ver, programı çalıştır, sonucu bir yanıta eşle. Bu disiplinle birlikte dışa aktarılan handler’lar hiçbir mantık taşımaz. İşte projedeki 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}`))
Handler iki şey yapar: ham gövdeyi ve alan adını olaydan çeker ve bir programa geçirir. Redirect handler’ı da event.pathParameters üzerinde aynı iki satırdır. Diğer her şey, decode, dallanma, hata eşlemesi, tipli programda ve tek bir çalıştırma noktasında toplanır.
Programın kendisi yukarıdan aşağıya okunur. src/programs.ts dosyasından:
export const createProgram = (body: string | null | undefined, baseUrl: string) =>
decodeBody(body).pipe(
Effect.flatMap(createLink),
Effect.map((record) => json(201, presentCreated(record, baseUrl))),
)
Her biri net bir tipe sahip üç adım: decodeBody güvenilmez bir string’i tipli bir isteğe çevirir, createLink iş mantığıdır ve json(201, ...) sonucu sunar. try/catch yok ve fonksiyonun ortasında seçilen bir statü yok. Sonraki bölümler her parçayı açar.
Sınırda Schema
effect/Schema güvenilmez girdiyi bir kez, kenarda decode eder. Programın içinde her değer zaten tipli ve doğrulanmıştır. Repo, sınır tiplerini src/domain/schema.ts içinde tanımlar:
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())),
})
İki ayrıntı önemli. Birincisi, ShortCode markalı (branded) bir string’tir. Ham bir string, bir ShortCode beklenen yere geçirilemez; compiler bunu reddeder. Birini şema üzerinden oluşturursunuz, dolayısıyla markayı taşıyan bir değer pattern kontrolünden gerçekten geçmiştir. Testlerde ShortCode.make("promo") bir tane oluşturur.
İkincisi, LinkRecord şeması, clicks için varsayılan dahil, DynamoDB öğe şeklinin tanımlandığı tek yerdir:
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 hataları if kontrolleri olarak dağılmaz. Sınırın bir kez eşlediği tek bir ParseError olurlar. src/http.ts içinde decodeBody JSON’u ayrıştırır, CreateLinkRequest’e karşı decode eder ve herhangi bir ParseError’ı, biçimlenmiş sorunları taşıyan tagged bir InvalidRequest’e çevirir:
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) }),
),
)
Layer’ların arkasındaki servisler
Bağımlılıklar Context.Tag olarak bildirilir ve Layer’lar tarafından sağlanır. Program neye ihtiyaç duyduğunu belirtir; bağlantı dışarıdan verilir ve değiştirilebilir. Depolama portu, src/services/LinkStore.ts dosyasında, üç metotlu bir tag’dir:
export class LinkStore extends Context.Tag("app/LinkStore")<
LinkStore,
LinkStoreService
>() {}
Uygulama, bir DynamoDB document layer’ı ile bir tablo adı alan makeLinkStoreLayer adlı bir fabrikada tanımlanır. Uygulama bu şekilde parametreleştirildiği için aynı fabrika hem üretim hem de yerel layer’ları kurar. Üretim, tablo adını SST’nin bağlı kaynağından okur:
export const LinkStoreLive = makeLinkStoreLayer(
DynamoDBDocument.layer({ marshallOptions: { removeUndefinedValues: true } }),
Resource.Links.name,
)
Yerel layer, aynı @effect-aws/dynamodb document istemcisini özel bir endpoint üzerinden DynamoDB Local’a yönlendirir:
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)
Bir fabrika, iki layer ve aralarında değişmeyen bir program. Effect dokümanları Context.Tag’i tek bir varsayılanın oturmadığı, uygulamanın ortam başına seçilmesi gereken durumlara yönlendirirken, Effect.Service net bir varsayılanı olan uygulama koduna uyar. Buradaki store ders kitabı niteliğinde bir tag durumudur: üretim, yerel ve testler her biri kendi uygulamasını sağlar. Buna karşılık, CodeGen (kısa kod üreticisi) net bir varsayılana sahiptir, bu yüzden sınıf içi bir Default layer’ı olan Effect.Service kullanır.
Statü Tablosu: Tagged Error Eşlemesi
Alan hataları, src/domain/errors.ts içinde bir kez tanımlanan Data.TaggedError sınıflarıdır:
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
Her hata bir _tag taşıdığı için, statü eşlemesi tam olarak tek bir yerde bulunur. withErrorMapping, bilinen küme için Effect.catchTags, beklenmeyen her şey için Effect.catchAllDefect kullanır, böylece yanıt şekli bir kez kararlaştırılır:
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" }))),
)
Effect dokümanlarına göre Effect.catchTags, birden fazla hatayı _tag alanlarını kullanarak tek bir blokta ele alır. Hata kanalındaki dönüş tipi never’dır; bu, compiler’ın her AppError durumunun ele alındığını kanıtlamasıdır. Yeni bir hata modu eklemek mekaniktir: bir tagged error bildir, bu tabloya bir satır ekle. Buradaki ödün, bu dolaylamanın ancak bir-iki hata durumundan fazlasına sahip olduğunuzda işe yaramasıdır; tek satırlık bir S3-tetikli Lambda için aşırı olurdu.
Tek bir conditional put, iki çakışma politikası
Create yolunda kısa bir kod seçmenin iki yolu var ve farklı çakışma davranışı gerekiyor. Karar, sonuç başına hangi politikanın uygulanacağıdır:
Her iki dal da aynı DynamoDB conditional put’unu kullanır. Store layer’ı bir öğeyi ConditionExpression: "attribute_not_exists(shortCode)" ile koyar. @effect-aws/dynamodb, başarısız bir koşulu _tag’i "ConditionalCheckFailedException" olan bir hata olarak yüzeye çıkarır; layer bunu alan hatası ShortCodeTaken’a çevirir:
Effect.catchAll((e): Effect.Effect<never, ShortCodeTaken | StoreUnavailable> =>
e._tag === "ConditionalCheckFailedException"
? Effect.fail(new ShortCodeTaken({ shortCode: record.shortCode }))
: Effect.fail(storeError(e)),
)
Politika farkı src/core/createLink.ts içinde yer alır. Çağıran bir özel kod verdiyse, çakışma gerçek bir 409’dur ve fonksiyon doğrudan hata verir. Kullanıcı tam olarak o kodu istedi; tekrar denemek yanlış olurdu. Kod üretildiyse, çakışma farklı bir tane denemek demektir, sınırlı şekilde:
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 sade bir özyinelemeli (recursive) yardımcıdır: üret, kalıcılaştır ve ShortCodeTaken’da bir deneme eksiğiyle özyinele. Denemeler bittiğinde son çakışma StoreUnavailable’a (bir 5xx) dönüşür; çünkü boş bir kod bulamamak bir store sorunudur, bir istemci hatası değil; çağıran yanlış bir şey yapmadı, dolayısıyla bir 409 yanlış sinyal olurdu. Özyineleme yukarıdan aşağıya okunur ve ekstra bir combinator gerektirmez.
Neden özyineleme, Effect.retry değil
Proje Effect.retry ile başladı, sonra özyinelemeli yardımcıya geçti. İşin doğrusu, neden bir daraltma (narrowing) tuzağı; özyinelemenin her zaman daha iyi olduğu iddiası değil. Effect.retry bir while yerine bir until (durdurma) seçeneği alır, dolayısıyla “yalnızca ShortCodeTaken’da yeniden dene” tersten until: (e) => e._tag !== "ShortCodeTaken" olarak yazılmak zorundadır. Bu çift olumsuzlamayı yanlışlıkla ters yazmak çok kolaydır. Daha kötüsü, çıplak bir yüklem (predicate) bir Refinement olarak çıkarsanır; bu da hata kanalını daraltır; daraltılan tip, tükenen durumu StoreUnavailable’a çevirmesi gereken sonraki catchTag’i bozar. Özyinelemeli yardımcı her iki sorunu da aşar ve tükenmeyi StoreUnavailable’a çeviren dönüşümü göz önünde tutar. Schedule ile Effect.retry, gerçekten geçici, zamana dayalı yeniden denemeler için doğru araçtır; buradaki çakışma yönetimi bunların hiçbiri değil.
Layer değiştirerek test
Üretim için var olan layer sınırı, testleri önemsizleştiren sınırla aynıdır. Testler bir bellek içi LinkStore (aynı arayüzün arkasında bir Map) ve deterministik bir CodeGen (sabit bir kod kuyruğu) sağlar; AWS yok, ağ yok. @effect/vitest’in it.effect’i her testi, bu layer’lar sağlanmış bir Effect olarak çalıştırır. Örneğin çakışmada yeniden deneme davranışı sade değerlerle doğrulanır:
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")
}),
)
İlk iki üretilen kod, tohumlanmış satırlarla çakışır; üçüncüsü boştur; doğrulama tek bir expect’tir. Burada dependency injection ayrı bir test iskelesi değildir. Üretim handler’ının kullandığı mekanizmanın aynısı, farklı layer’lara yönlendirilmiştir.
ManagedRuntime ve yönlendirmeyi bozmayan tıklama sayacı
Runtime bir kez, modül kapsamında, src/lambda.ts içinde kurulur:
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, bir yapılandırma layer’ını, effect’leri çalıştırabileceğiniz bir runtime’a çevirir. Onu modül kapsamına yerleştirmek tasarım niyetidir: runtime ve layer’ları, her istekte yeniden kurulmak yerine sıcak (warm) container başına bir kez kurulur ve çağrılar arasında yeniden kullanılır. Bu yapısal bir seçimdir, ölçülmüş bir hızlanma değil.
Redirect yolu, mutlaka çalışması gerekeni olsa iyi olandan ayırır. Redirect’in kendisi çalışmak zorunda; tıklama sayacı analitiktir. src/core/visit.ts içinde artırma, Effect.orElseSucceed ile yönlendirmeyi bozmayacak biçimde yapılır:
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 }
})
Sayaç güncellemesi başarısız olursa, clicks undefined’a düşer ve redirect yine de URL’i döndürür. Yerel test bunu doğrular: zorlanmış bir artırma hatasıyla result.url hâlâ doğrudur ve result.clicks undefined’dır. Store ayrıca güncellemeyi attribute_exists(shortCode) ile korur, böylece sayaç yalnızca var olan bir öğeye uygulanır.
SST v4 ile Kurulum
Tek bir sst.config.ts tabloyu ve rotaları bildirir. TTL özniteliği tabloda ayarlanır, böylece DynamoDB linkleri kendisi süresi dolmuş kabul eder:
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] })
link: [table] dizisi tabloyu bağlı bir kaynak yapar; bu yüzden import { Resource } from "sst", LinkStoreLive içinde çalışma zamanında Resource.Links.name’i erişilebilir kılar. İki dev döngüsü var: sst dev canlı bir Lambda sağlar (provision eder), yerel bir döngü ise (local/server.ts ile docker-compose.yml üzerinden DynamoDB Local) hızlı çevrimdışı yineleme için LinkStoreLocal layer’ını bağlar. Bu yine layer değişimidir, bu sefer test sınırında değil deploy sınırında.
Yaygın tuzaklar
Bunu kurarken birkaç pürüz ortaya çıktı; deseni kopyalamadan önce bilmeye değer.
vitest ana sürümü ile @effect/vitest peer’ı. @effect/[email protected], vitest ^3.2 ve effect ^3.21 peer’larını bildirir. vitest-4 uyumlu bir @effect/vitest, yalnızca Effect 4 beta’sına ihtiyaç duyan bir 4.x beta olarak var. Reponun package.json’ı, tüm yığını Effect 4 beta’sına sürüklemek yerine vitest ^3.2.0’ı @effect/vitest ^0.29.0 ve effect ^3.21.2 ile birlikte sabitler ve vitest 3’te kalır. Bu seçim, bilinen, yalnızca-dev, yalnızca-UI bir vitest advisory’siyle gelir (vitest --ui sunucusu); bu proje headless çalışır ve vitest’i asla Lambda’ya bundle etmez, dolayısıyla advisory bilinçli olarak kabul edilir. Yükseltmeden önce npm peer aralıklarını kontrol edin.
TestClock epoch 0’da başlar. it.effect altında TestClock, siz ilerletene kadar 0’da başlar ve zamanın başlangıcını simüle eder. Süre dolma testleri zaman damgalarını buna göre tohumlamalı; reponun testleri createdAt: 1 kullanır. Wall-clock Date.now() varsaymak, süre dolma doğrulamalarını kafa karıştırıcı şekilde başarısız kılar. TestClock’u bilinçli kullanın veya gerçekten gerçek zaman istediğinizde it.live’a başvurun.
DynamoDB Local hazır olmadan hazır olduğunu bildirir. docker compose up -d, TCP portu açıldığında geri döner, ama Java süreci henüz istekleri sunmuyor olabilir. Bir port kontrolü bir hazırlık kontrolü değildir. Süiti çalıştırmadan önce gerçek bir isteği, örneğin endpoint’e karşı bir describe veya list’i yoklayın; repo bunu local/setup.ts içinde ele alır.
Markalı tipler cast yaparsanız yardımcı olmaz. Bir ShortCode markası, yalnızca şema üzerinden (ShortCode.make(...) veya Schema.decode) geçerseniz herhangi bir şeyi korur. value as ShortCode yazmak kontrolü derleme zamanında devre dışı bırakır ve doğrulanmamış bir string’in geçmesine izin verir. Markalı bir tipin söz konusu olduğu her yerde as cast’ini bir tehlike işareti olarak görün.
Kullanım Senaryoları
Tipli bir Effect programının önünde ince bir adaptör olan handler deseni, hata yönetimi ve bağımlılık bağlantısının aksi halde yayılacağı durumlarda karşılığını verir: her biri ayrı bir HTTP statüsü gerektiren birkaç hata modu, ortam başına değiştirmek istediğiniz bağımlılıklar ve AWS’ye dokunmadan test etmek istediğiniz mantık. Klasik try/catch handler’ının tam olarak kaydığı koşullar bunlardır. Bunun yerine, fonksiyon gerçekten önemsizken, tek satırlık bir dönüşüm veya tek bir S3 tetikleyicisi gibi, Effect’in söz dağarcığının (Tag, Layer, tagged error) az karşılığı olan bir ön maliyet olduğu yerde sade handler’a başvurun. Benimseme için iki dürüst uyarı: Effect, gerçek bir öğrenme eğrisi ve yüzey alanı maliyeti olan bir bağımlılıktır ve @effect-aws/*, AWS tarafından yayınlanan bir SDK değil bir topluluk projesidir.
Şekli denemek isterseniz, sonraki somut adım repoyu klonlamak, 27 testi bir AWS hesabı olmadan çalıştırmak ve src/programs.ts ile src/http.ts’i birlikte okumaktır; bu iki dosya ince adaptör fikrinin okunabilir hale geldiği yerdir. Buradan sonraki doğal adım, onu deploy edip cold start p50/p99 ile istek başına DynamoDB kapasitesini ölçmektir.
Kaynaklar
- Effect: Introduction to Schema -
Schema.brand,StructveoptionalWithile sınırda decode - Effect: Managing Services -
Context.TagveEffect.ServiceileContext.Tagkarşılaştırması - Effect: Managing Layers - Layer’ları kurma, birleştirme ve bağımlılık olarak sağlama
- Effect: Introduction to Runtime - Üst düzey, yeniden kullanılabilir bir runtime için
ManagedRuntime.make - Effect: Expected Errors -
Data.TaggedErrorvecatchTag/catchTagsile_tag’e göre ele alma - Effect: Retrying -
Effect.retry,untilyüklemi veSchedule - floydspace/effect-aws - Topluluk geliştirmesi Effect-native AWS istemcileri (AWS yayını bir SDK değil)
- @effect-aws/dynamodb (npm) -
DynamoDBDocument,baseLayerve conditional-check hata tag’i - SST: Dynamo component -
sst.aws.Dynamo, fields,primaryIndexvettl - SST: ApiGatewayV2 component -
api.route("METHOD /path", { handler, link }) - SST: Resource Linking -
link: [resource]veimport { Resource } from "sst" - nanoid (npm) - Kısa, URL-güvenli ID üretimi
- AWS: Deploying DynamoDB locally (Docker) - Çevrimdışı testler için yerel tablo
- @effect/vitest (npm) -
it.effect,TestClockve peer aralıkları - ayhansipahi/effect-link-shortener - Link kısaltıcının eksiksiz, kurulabilir kaynak kodu
İlgili yazılar
Effect'i adım adım öğrenmek ve AWS Lambda ile entegre etmek için pratik bir rehber: gerçek kod örnekleri, yaygın hatalar ve üretim desenleri.
CDK TypeScript Lambda için 9 bundler ve 3 cdk synth runner'ının ölçümlü karşılaştırması; her katman için varsayılan ve onu seçtiren kural.
Mimari ağırlığını runtime'ın init-amortismanına göre seç: single-purpose Lambda'da yalın handler, Lambdalith'te orta, tam OOP/DI yalnızca uzun ömürlü runtime'da.
AWS Lambda fonksiyonlarını nasıl bölmeli: varsayılan single-purpose, single-domain Lambdalith'i hak edilmiş istisna gör, kararı veren platform güçlerini tanı.
DI container'lar, monolitik SDK'lar, god-handler'lar, modül üstü secret çağrıları ve ağır ORM'ler - soğuk başlatmada bedeli ve yerine geçen fonksiyonel yapı.