| 1 | --- |
| 2 | title: DynamoDB |
| 3 | description: Add a DynamoDB Table, bind GetItem and PutItem to your Lambda, and serve a typed key/value HTTP API backed by DynamoDB. |
| 4 | --- |
| 5 | |
| 6 | **DynamoDB** is AWS's serverless key/value store — structured |
| 7 | records looked up by partition (and optional sort) key, with no |
| 8 | capacity to manage. In Alchemy it's the `Table` resource plus a |
| 9 | binding per operation: `GetItem`, `PutItem`, `UpdateItem`, |
| 10 | `DeleteItem`, `Query`, `Scan`, and the batch/transact variants. |
| 11 | Each binding attaches a least-privilege IAM statement scoped to |
| 12 | the table's ARN. Tables are also an event source: enable |
| 13 | **DynamoDB Streams** and consume change records in a Lambda with |
| 14 | `DynamoDB.consumeTableChanges` (see the |
| 15 | [DynamoDB Streams guide](/aws/messaging/dynamodb-streams)). |
| 16 | |
| 17 | The walkthrough below builds on the [Lambda](/aws/compute/lambda) and |
| 18 | [S3](/aws/data/s3) pages: it adds a **Table** and binds `GetItem` + |
| 19 | `PutItem` to the function — same pattern as S3, different |
| 20 | storage. |
| 21 | |
| 22 | ## Define the table |
| 23 | |
| 24 | DynamoDB tables need a partition key (and optionally a sort key) |
| 25 | declared up-front. Yield the `Table` resource in the function's |
| 26 | constructor, mirroring how you added the bucket: |
| 27 | |
| 28 | ```diff lang="typescript" |
| 29 | // src/api.ts |
| 30 | import * as AWS from "alchemy/AWS"; |
| 31 | +import * as DynamoDB from "alchemy/AWS/DynamoDB"; |
| 32 | import * as S3 from "alchemy/AWS/S3"; |
| 33 | import { Stack } from "alchemy/Stack"; |
| 34 | import * as Effect from "effect/Effect"; |
| 35 | // ... |
| 36 | |
| 37 | Effect.gen(function* () { |
| 38 | const bucket = yield* S3.Bucket("Blobs"); |
| 39 | + const table = yield* DynamoDB.Table("Items", { |
| 40 | + partitionKey: "id", |
| 41 | + attributes: { |
| 42 | + id: "S", |
| 43 | + }, |
| 44 | + }); |
| 45 | |
| 46 | // ... existing bindings + handler ... |
| 47 | }); |
| 48 | ``` |
| 49 | |
| 50 | `attributes` only needs to list the attributes used by keys (and |
| 51 | secondary indexes) — DynamoDB itself is schemaless on the rest of |
| 52 | the row. `"S"` is the AWS shorthand for "string"; `"N"` is number |
| 53 | and `"B"` is binary. |
| 54 | |
| 55 | ## Bind GetItem and PutItem |
| 56 | |
| 57 | Same shape as the S3 bindings — `PutItem(table)` returns a callable |
| 58 | Effect and quietly attaches a least-privilege IAM policy scoped to |
| 59 | this table's ARN: |
| 60 | |
| 61 | ```diff lang="typescript" |
| 62 | Effect.gen(function* () { |
| 63 | const bucket = yield* S3.Bucket("Blobs"); |
| 64 | const table = yield* DynamoDB.Table("Items", { /* ... */ }); |
| 65 | |
| 66 | const putObject = yield* S3.PutObject(bucket); |
| 67 | const getObject = yield* S3.GetObject(bucket); |
| 68 | + const putItem = yield* DynamoDB.PutItem(table); |
| 69 | + const getItem = yield* DynamoDB.GetItem(table); |
| 70 | |
| 71 | return { fetch: /* ... */ }; |
| 72 | }); |
| 73 | ``` |
| 74 | |
| 75 | ## Write items with `PUT /items/:id` |
| 76 | |
| 77 | Add a branch to `fetch` that writes a row keyed by the URL |
| 78 | segment. DynamoDB takes attribute values in their native typed |
| 79 | form — `{ S: "..." }` for strings, `{ N: "42" }` for numbers — |
| 80 | and Alchemy bindings deliberately **don't** auto-marshal so you |
| 81 | stay on the raw SDK shape: |
| 82 | |
| 83 | ```diff lang="typescript" |
| 84 | fetch: Effect.gen(function* () { |
| 85 | const request = yield* HttpServerRequest; |
| 86 | const url = new URL(request.url); |
| 87 | const path = url.pathname; |
| 88 | |
| 89 | + if (path.startsWith("/items/") && request.method === "PUT") { |
| 90 | + const id = path.slice("/items/".length); |
| 91 | + const content = yield* request.text; |
| 92 | + yield* putItem({ |
| 93 | + Item: { |
| 94 | + id: { S: id }, |
| 95 | + content: { S: content }, |
| 96 | + }, |
| 97 | + }); |
| 98 | + return HttpServerResponse.empty({ status: 204 }); |
| 99 | + } |
| 100 | |
| 101 | // ... existing /:key blob routes ... |
| 102 | }).pipe(Effect.orDie), |
| 103 | ``` |
| 104 | |
| 105 | Skipping marshalling keeps the binding tiny and avoids a |
| 106 | debate over which marshalling layer to use. If you want a |
| 107 | typed-object API across your codebase, build it as a thin |
| 108 | wrapper around `putItem` — the binding won't fight you. |
| 109 | |
| 110 | ## Read items with `GET /items/:id` |
| 111 | |
| 112 | The read path uses `getItem` with the same `{ S: id }` key |
| 113 | shape: |
| 114 | |
| 115 | ```diff lang="typescript" |
| 116 | if (path.startsWith("/items/") && request.method === "PUT") { |
| 117 | // ... unchanged ... |
| 118 | } |
| 119 | |
| 120 | +if (path.startsWith("/items/") && request.method === "GET") { |
| 121 | + const id = path.slice("/items/".length); |
| 122 | + const result = yield* getItem({ Key: { id: { S: id } } }); |
| 123 | + if (!result.Item) { |
| 124 | + return HttpServerResponse.text("Not found", { status: 404 }); |
| 125 | + } |
| 126 | + return yield* HttpServerResponse.json({ |
| 127 | + id: result.Item.id?.S, |
| 128 | + content: result.Item.content?.S, |
| 129 | + }); |
| 130 | +} |
| 131 | ``` |
| 132 | |
| 133 | `getItem` returns `result.Item` as `undefined` when the row |
| 134 | doesn't exist — no need for a typed `catchTag` like S3's |
| 135 | `NoSuchKey`. Map the AWS attribute-value shape back to a plain |
| 136 | object at the response boundary. |
| 137 | |
| 138 | ## Provide the runtime layers |
| 139 | |
| 140 | Add the DynamoDB lives next to the S3 ones: |
| 141 | |
| 142 | ```diff lang="typescript" |
| 143 | }).pipe( |
| 144 | Effect.provide( |
| 145 | Layer.mergeAll( |
| 146 | AWS.Lambda.BucketEventSource, |
| 147 | + DynamoDB.GetItemHttp, |
| 148 | + DynamoDB.PutItemHttp, |
| 149 | S3.PutObjectHttp, |
| 150 | S3.GetObjectHttp, |
| 151 | ), |
| 152 | ), |
| 153 | ), |
| 154 | ``` |
| 155 | |
| 156 | ## Deploy and verify |
| 157 | |
| 158 | ```sh |
| 159 | bun alchemy deploy |
| 160 | ``` |
| 161 | |
| 162 | Add a round-trip test that exercises the new routes: |
| 163 | |
| 164 | ```typescript |
| 165 | // test/integ.test.ts |
| 166 | test( |
| 167 | "DynamoDB round-trip", |
| 168 | Effect.gen(function* () { |
| 169 | const { url } = yield* stack; |
| 170 | |
| 171 | yield* HttpClient.put(`${url}/items/abc`, { |
| 172 | body: HttpBody.text("hello dynamo"), |
| 173 | }); |
| 174 | |
| 175 | const get = yield* HttpClient.get(`${url}/items/abc`); |
| 176 | expect(yield* get.json).toEqual({ id: "abc", content: "hello dynamo" }); |
| 177 | }), |
| 178 | ); |
| 179 | ``` |
| 180 | |
| 181 | ```sh |
| 182 | bun test test/integ.test.ts |
| 183 | ``` |
| 184 | |
| 185 | The IAM policy attached to your function role now includes |
| 186 | `dynamodb:GetItem` and `dynamodb:PutItem` — both scoped to this |
| 187 | specific table — alongside the S3 grants. |
| 188 | |
| 189 | ## Where next |
| 190 | |
| 191 | - [Process DynamoDB Streams](/aws/messaging/dynamodb-streams) — |
| 192 | fan table changes out into a Lambda-side `Stream` you can |
| 193 | react to. |
| 194 | - [SQS](/aws/messaging/sqs) — a general-purpose queue for background |
| 195 | work, fed from the same Lambda. |
| 196 | - [`Table` reference](/providers/aws/dynamodb/table) — every |
| 197 | prop and attribute, including secondary indexes, TTL, and |
| 198 | point-in-time recovery. |