One item, start to finish
Every snippet below runs against the library's test suite. The same code appears in the README and guides.
Define the repo once
The Repo is a supervised process tree. It resolves credentials (config, environment, ECS, IMDSv2) and refreshes them before they expire, owns region and endpoint, retries throttling and 5xx with jittered backoff, and emits telemetry for every request.
Point it at DynamoDB Local in dev, at a Req.Test plug in tests, at AWS in prod. Nothing else changes.
defmodule MyApp.Dynamo do
use Dynamo.Repo, otp_app: :my_app
end
# config/runtime.exs
config :my_app, MyApp.Dynamo,
region: System.get_env("AWS_REGION", "eu-central-1"),
endpoint: System.get_env("DYNAMO_ENDPOINT")
# application.ex
children = [MyApp.Dynamo]
Write it
Fields are dumped with their declared type. Nil fields are omitted, so an item never carries NULL noise. The condition is a keyword list; Dynamo generates the expression, the placeholders and the typed values.
Embedded schemas and {:list, type} keep their types on the way back: dates stay dates, decimals stay decimals.
order = %MyApp.Order{
user_id: "u1",
placed_at: DateTime.utc_now(),
order_id: "o1",
total: Decimal.new("9.99")
}
{:ok, order} = MyApp.Dynamo.put(order, condition: [pk: :not_exists])
# {:error, %Dynamo.Error{type: :conditional_check_failed}} if it existed
Read it back
Populated sort-key fields on the struct become a begins_with on the partial key, so "all of January" is one line. Pages carry last_evaluated_key; stream/2 paginates lazily and segments: turns a scan into a genuinely parallel one with backpressure.
{:ok, %Dynamo.Page{items: orders, last_evaluated_key: key}} =
MyApp.Dynamo.query(%MyApp.Order{user_id: "u1"},
sort_key: {:between, "2024-01-01", "2024-01-31~"},
filter: [total: {:gt, Decimal.new("100")}],
descending: true,
limit: 50
)
# January, via the struct: sk begins_with "2024-01"
MyApp.Dynamo.query(%MyApp.Order{user_id: "u1",
placed_at: ~U[2024-01-01 00:00:00Z]})
# index keys keep the field's type
MyApp.Dynamo.query(%MyApp.Order{status: "shipped"},
index: "StatusIndex", sort_key: {:gt, ~U[2024-01-01 00:00:00Z]})
MyApp.Dynamo.stream(MyApp.Order, segments: 8) |> Enum.count()
Change it
SET, REMOVE, ADD and DELETE are built from a keyword list. Key fields cannot be updated; that is a validation error before any request is sent, not a corrupted item afterwards.
The client retries transport timeouts, so guard counters with a version condition or use a transaction with an idempotency token.
{:ok, nil} =
MyApp.Dynamo.update(order,
[status: "shipped", version: {:increment, 1}],
condition: [version: order.version]
)
# UpdateExpression: SET #a0 = :v0, #a1 = #a1 + :v1
# ConditionExpression: #c0 = :c1
MyApp.Dynamo.update(order,
tags: {:add, MapSet.new(["gift"])},
legacy: :remove
)
Do several things atomically
Transactions take the same structs and the same option names. On cancellation the error carries one reason per operation, in order, so you know which check failed.
MyApp.Dynamo.transaction(
[
{:update, %Account{id: "src"}, [balance: {:decrement, 100}],
condition: [balance: {:gte, 100}]},
{:update, %Account{id: "dst"}, [balance: {:increment, 100}]},
{:put, %Transfer{id: "t1", amount: 100}, condition: [id: :not_exists]}
],
client_request_token: "transfer-t1"
)
# on failure:
# {:error, %Dynamo.Error{type: :transaction_canceled,
# cancellation_reasons: [%{code: "ConditionalCheckFailed"},
# %{code: "None"}, %{code: "None"}]}}
What is actually stored
No hidden magic in the table. The key attributes are plain strings you can read in the console, and any other client can query them. raw_keys: true maps tables you did not design around this layout.
{
"pk": {"S": "order#u1"},
"sk": {"S": "2024-01-15T10:00:00Z#o1"},
"user_id": {"S": "u1"},
"placed_at": {"S": "2024-01-15T10:00:00Z"},
"order_id": {"S": "o1"},
"total": {"N": "9.99"},
"status": {"S": "pending"},
"version": {"N": "0"}
}
get/2 returns%MyApp.Order{
user_id: "u1",
placed_at: ~U[2024-01-15 10:00:00Z],
order_id: "o1",
total: Decimal.new("9.99"),
status: "pending",
version: 0
}