This document showcases every major feature of dynamodb-facade through an
Online Learning Platform domain: users, courses, enrollments, assignments,
and platform configuration — all stored in a single DynamoDB table (mono-table
pattern).
Where relevant, the equivalent raw aws-sdk-dynamodb code is shown for
comparison.
- 1. Schema Definitions
- 2. Item Definitions
- 3. CRUD Operations
- 3.1 Get — Typed
- 3.2 Get — Raw (Projection)
- 3.3 Put — Unconditional
- 3.4 Put — not_exists (Create-Only)
- 3.5 Put — Custom Condition
- 3.6 Delete — By ID with exists
- 3.7 Delete — Instance (Unconditional)
- 3.8 Update — set + exists
- 3.9 Update — Compound (set + remove)
- 3.10 Update — combine (Optional Fields)
- 3.11 Update — Atomic Counters
- 3.12 Update — return_new / return_none
- 3.13 Update — Custom Condition
- 4. Query Operations
- 5. Scan Operations
- 6. Condition Expressions
- 7. Update Expressions
- 8. Key Conditions
- 9. Batch Operations
- 10. Transactions
- 11. Error Handling
- 12. Item Inspection APIs
- 13. Typestate Builder Transitions
Attribute definitions declare DynamoDB attribute names and their types (S, N, B). They are used as type-level identifiers throughout the library.
use dynamodb_facade::{attribute_definitions, StringAttribute, NumberAttribute};
attribute_definitions! {
/// Partition key for the platform mono-table.
PK { "PK": StringAttribute }
/// Sort key for the platform mono-table.
SK { "SK": StringAttribute }
/// Item type discriminator (single-table design).
ItemType { "_TYPE": StringAttribute }
/// TTL attribute for expiring items.
Expiration { "expiration_timestamp": NumberAttribute }
/// Email attribute, used as a Index partition key.
Email { "email": StringAttribute }
/// Searchable unique identifier, used as a Index partition key.
SearchId { "id": StringAttribute }
}Each invocation generates a zero-sized pub struct that implements AttributeDefinition,
providing const NAME: &str and type Type.
use dynamodb_facade::table_definitions;
table_definitions! {
/// The platform mono-table with composite key (PK + SK).
PlatformTable {
type PartitionKey = PK;
type SortKey = SK;
fn table_name() -> String {
std::env::var("TABLE_NAME")
.expect("TABLE_NAME env var must be set")
}
}
}Generates a pub struct PlatformTable implementing TableDefinition with
CompositeKeySchema.
table_definitions! {
/// A simple-key table (PK only, no sort key).
SimpleTable {
type PartitionKey = PK;
fn table_name() -> String {
std::env::var("SIMPLE_TABLE_NAME")
.expect("SIMPLE_TABLE_NAME env var must be set")
}
}
}Generates SimpleKeySchema — no sort key methods available at compile time.
use dynamodb_facade::index_definitions;
// table_definitions! and index_definitions! can have multiple definitions in one call-site
index_definitions! {
/////////
// Index with partition key only (simple key).
/////////
/// Index on item type — query all items of a given type.
#[table = PlatformTable]
TypeIndex {
type PartitionKey = ItemType;
fn index_name() -> String { "iType".to_owned() }
}
/////////
// Index with partition key only.
/////////
/// Index on email — look up any item by email.
#[table = PlatformTable]
EmailIndex {
type PartitionKey = Email;
fn index_name() -> String { "iEmail".to_owned() }
}
/////////
// Index with composite key.
/////////
/// Index on searchable ID + type — composite key index.
#[table = PlatformTable]
IdTypeIndex {
type PartitionKey = SearchId;
type SortKey = ItemType;
fn index_name() -> String { "iIdType".to_owned() }
}
}For items that exist as a single row (e.g. platform configuration):
use dynamodb_facade::{dynamodb_item, KeyId, NoId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlatformConfig {
pub max_enrollments: u32,
pub maintenance_mode: bool,
}
dynamodb_item! {
#[table = PlatformTable]
PlatformConfig {
#[partition_key]
PK { const VALUE: &'static str = "PLATFORM_CONFIG"; }
#[sort_key]
SK { const VALUE: &'static str = "PLATFORM_CONFIG"; }
ItemType { const VALUE: &'static str = "PLATFORM_CONFIG"; }
}
}Both PK and SK are compile-time constants. Access via KeyId::NONE:
let config = PlatformConfig::get(client, KeyId::NONE).await?;The most common pattern — entity keyed by a unique ID:
use uuid::Uuid;
type ID = Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: ID,
pub name: String,
pub email: String,
pub role: String,
}
dynamodb_item! {
#[table = PlatformTable]
User {
#[partition_key]
PK {
fn attribute_id(&self) -> ID { self.id }
fn attribute_value(id) -> String { format!("USER#{id}") }
}
#[sort_key]
SK { const VALUE: &'static str = "USER"; }
ItemType { const VALUE: &'static str = "USER"; }
#[marker_only]
Email {
fn attribute_id(&self) -> &str { &self.email }
fn attribute_value(id) -> String { id.to_owned() }
}
}
}attribute_id extracts the logical ID from the struct. attribute_value builds
the DynamoDB attribute string from that ID. Access via KeyId::pk(user_id).
#[marker_only] signals attributes that are already part of the type serialization
and prevents the macro from adding the attribute to the
DynamoDBItem::AdditionalAttributes associated type. It stills implement HasAttribute
on the type, signaling it is part of an index (in this example, User becomes part of
EmailIndex from the type-system perpective).
For child entities stored under a parent's partition:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Enrollment {
pub user_id: ID,
pub course_id: ID,
pub enrolled_at: u64,
pub progress: f64,
}
dynamodb_item! {
#[table = PlatformTable]
Enrollment {
#[partition_key]
PK {
fn attribute_id(&self) -> ID { self.user_id }
fn attribute_value(id) -> String { format!("USER#{id}") }
}
#[sort_key]
SK {
fn attribute_id(&self) -> ID { self.course_id }
fn attribute_value(id) -> String { format!("ENROLL#{id}") }
}
ItemType { const VALUE: &'static str = "ENROLLMENT"; }
}
}Both PK and SK are dynamic. Access via KeyId::pk(user_id).sk(course_id).
Query all enrollments for a user via Enrollment::key_condition(user_id).
When a wrapper type shares key layout with an existing type:
#[derive(Debug, Clone, Serialize)]
pub struct UserWithSecret<'a> {
#[serde(flatten)]
user: &'a User,
secret: String,
}
dynamodb_item! {
#[table = PlatformTable]
UserWithSecret<'_> {
#[partition_key]
PK {
fn attribute_id(&self) -> <User as HasAttribute<PK>>::Id<'id> {
<User as HasAttribute<PK>>::attribute_id(self.user)
}
fn attribute_value(id) -> <User as HasAttribute<PK>>::Value {
<User as HasAttribute<PK>>::attribute_value(id)
}
}
#[sort_key]
SK { const VALUE: &'static str = <User as HasConstAttribute<SK>>::VALUE; }
ItemType { const VALUE: &'static str = <User as HasConstAttribute<ItemType>>::VALUE; }
}
}Uses fully-qualified associated types to delegate key generation to User.
When serde round-trip is insufficient (e.g. enum stored as a single attribute):
use dynamodb_facade::{
attr_list, has_attributes, DynamoDBItem, Error, IntoAttributeValue, Item,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub enum CourseStatus {
Draft,
Published,
Archived,
}
impl DynamoDBItem<PlatformTable> for CourseStatus {
type AdditionalAttributes = attr_list!(ItemType);
fn to_item(&self) -> Item<PlatformTable>
where
Self: Serialize,
{
let minimal_item = Item::minimal_from(self);
minimal_item
.with_attributes([("status".to_owned(), self.to_string().into_attribute_value())])
}
fn try_from_item(item: Item<PlatformTable>) -> dynamodb_facade::Result<Self> {
item.get("status")
.ok_or_else(|| Error::custom("Missing 'status' attribute"))
.and_then(|av| {
av.as_s()
.map_err(|e| Error::custom(format!("Invalid schema: {e:?}")))
})
.and_then(|s| s.parse().map_err(Error::other))
}
}
has_attributes! {
CourseStatus {
PK { const VALUE: &'static str = "COURSE_STATUS"; }
SK { const VALUE: &'static str = "COURSE_STATUS"; }
ItemType { const VALUE: &'static str = "COURSE_STATUS"; }
}
}has_attributes! replaces manual HasConstAttribute / HasAttribute impls.
For domain newtypes that should map to DynamoDB attribute values:
use dynamodb_facade::{IntoAttributeValue, AttributeValue};
pub struct EmailAddress(String);
impl IntoAttributeValue for EmailAddress {
fn into_attribute_value(self) -> AttributeValue {
self.0.into_attribute_value()
}
}
pub struct Credits(u32);
impl IntoAttributeValue for Credits {
fn into_attribute_value(self) -> AttributeValue {
self.0.into_attribute_value()
}
}These can then be used directly in expressions: Update::set("email", email_address).
Returns a deserialized Option<T>:
// Singleton (const PK + SK)
let config = PlatformConfig::get(client, KeyId::NONE).await?;
// By ID (variable PK, const SK)
let user = User::get(client, KeyId::pk(user_id)).await?;
// By composite key (variable PK + SK)
let enrollment = Enrollment::get(client, KeyId::pk(user_id).sk(course_id)).await?;Raw SDK equivalent:
// 16 lines of boilerplate per get:
let resp = client.get_item()
.table_name(table_name())
.key("PK", AttributeValue::S(format!("USER#{user_id}")))
.key("SK", AttributeValue::S("USER".to_owned()))
.send()
.await?;
let user = resp.item.map(|item| serde_dynamo::from_item(item).unwrap());When you need untyped access or a subset of attributes:
let raw_item = User::get(client, KeyId::pk(user_id))
.raw()
.await?;
// raw_item: Option<Item<PlatformTable>>Overwrites any existing item:
config.put(client).await?;Fails with ConditionalCheckFailedException if item already exists:
new_user.put(client).not_exists().await?;Raw SDK equivalent:
client.put_item()
.table_name(table_name())
.set_item(Some(serde_dynamo::to_item(&new_user).unwrap()))
.condition_expression("attribute_not_exists(PK)")
.return_values(ReturnValue::None)
.send()
.await?;Overwrite only if a specific condition holds (e.g. not exist or expired TTL):
course_status
.put(client)
.condition(
CourseStatus::not_exists()
| Condition::lt(Expiration::NAME, now_timestamp),
)
.await?;Returns the old item, fails if item doesn't exist:
let old_enrollment = Enrollment::delete_by_id(
client,
KeyId::pk(user_id).sk(course_id),
)
.exists()
.await?
.expect("exists() guarantees Some");delete_by_id defaults to Return<Old> — returns Option<T>.
.exists() adds attribute_exists(PK) condition.
Delete from an already-loaded item:
enrollment.delete(client).await?;User::update_by_id(
client,
KeyId::pk(user_id),
Update::set("name", new_name),
)
.exists()
.await?;
// Returns the updated User (default: Return<New>)Raw SDK equivalent:
client.update_item()
.table_name(table_name())
.key("PK", AttributeValue::S(format!("USER#{user_id}")))
.key("SK", AttributeValue::S("USER".to_owned()))
.update_expression("SET #name = :name")
.expression_attribute_names("#name", "name")
.expression_attribute_values(":name", AttributeValue::S(new_name))
.condition_expression("attribute_exists(PK)")
.return_values(ReturnValue::AllNew)
.send()
.await?
.attributes
.map(|attrs| serde_dynamo::from_item(attrs).unwrap())
.expect("asked for ALL_NEW");Chain multiple update actions with .and():
user.update(
client,
Update::set("email", new_email)
.and(Update::set("email_verified", true)),
)
.exists()
.await?;Conditional set/remove based on domain logic:
let update = if let Some(bio) = new_bio {
Update::set("bio", bio)
} else {
Update::remove("bio")
};
User::update_by_id(client, KeyId::pk(user_id), update)
.exists()
.await?;Merge an iterator of optional updates into one expression:
let update = Update::combine(
[
new_name.map(|n| Update::set("name", n)),
new_email.map(|e| Update::set("email", e)),
new_role.map(|r| Update::set("role", r)),
]
.into_iter()
.flatten(),
);
User::update_by_id(client, KeyId::pk(user_id), update)
.exists()
.await?;// Increment: SET clicks = clicks + 1
User::update_by_id(client, KeyId::pk(user_id), Update::increment("login_count", 1))
.exists()
.await?;
// Decrement: SET credits = credits - 5
User::update_by_id(client, KeyId::pk(user_id), Update::decrement("credits", 5))
.exists()
.await?;
// Init + increment: SET enrollments = if_not_exists(enrollments, 0) + 1
User::update_by_id(client, KeyId::pk(user_id), Update::init_increment("enrollments", 0, 1))
.exists()
.await?;Raw SDK equivalent for init_increment:
// .update_expression("SET #enrollments = if_not_exists(#enrollments, :zero) + :one")
// .expression_attribute_names("#enrollments", "enrollments")
// .expression_attribute_values(":zero", AttributeValue::N("0".to_owned()))
// .expression_attribute_values(":one", AttributeValue::N("1".to_owned()))Control what DynamoDB returns after mutation:
// Return the updated item (default for update_by_id):
let updated_user: User = User::update_by_id(client, KeyId::pk(user_id), update)
.exists()
.await?;
// Return nothing (skip deserialization cost):
User::update_by_id(client, KeyId::pk(user_id), update)
.exists()
.return_none()
.await?; // Result<()>
// Instance update returning the new item:
let updated: User = user.update(client, update)
.condition(some_condition)
.return_new()
.await?;// Update only if status matches:
User::update_by_id(client, KeyId::pk(user_id), Update::set("role", "instructor"))
.condition(Condition::eq("role", "student"))
.await?;
// Optimistic concurrency — check balance before deducting:
User::update_by_id(
client,
KeyId::pk(user_id),
Update::set("balance", new_balance),
)
.condition(
User::exists()
& Condition::eq("balance", old_balance),
)
.await?;Fetch all items under a partition (e.g. all enrollments for a user):
let enrollments /* : Vec<Enrollment> */ =
Enrollment::query(client, Enrollment::key_condition(user_id))
.all()
.await?;key_condition(pk_id) generates the appropriate KeyCondition from the type's
HasAttribute<PK> impl.
Fetch a subset of items by sort key prefix:
let assignments /* : Vec<Assignment> */ =
Assignment::query(
client,
Assignment::key_condition(course_id)
.sk_begins_with("ASSIGN#"),
)
.all()
.await?;For types with a constant partition key (singletons or collections with fixed PK):
let all_configs /* : Vec<PlatformConfig> */ =
PlatformConfig::query_all(client).all().await?;query_all is available when the type has HasConstAttribute<PK>.
Query a Global Secondary Index with a key condition:
use dynamodb_facade::KeyCondition;
let users_by_email /* : Vec<User> */ =
User::query_index::<EmailIndex>(
client,
KeyCondition::pk(email_address),
)
.all()
.await?;
// Composite Index — PK + SK condition:
let user_by_id_and_type /* : Vec<User> */ =
User::query_index::<IdTypeIndex>(
client,
KeyCondition::pk(search_id).sk_eq("USER"),
)
.all()
.await?;Raw-level index query (when entity type doesn't match):
use dynamodb_facade::QueryRequest;
let results /* : Vec<Item<PlatformTable>> */ =
QueryRequest::new_index::<EmailIndex>(
client,
KeyCondition::pk(email.to_string()),
)
.all()
.await?;Query all items of a given type via the TypeIndex:
let all_users /* : Vec<User> */ =
User::query_all_index::<TypeIndex>(client).all().await?;query_all_index is available when the type has HasConstAttribute for the PK of the queried index (HasConstAttribute<ItemType> in this example).
let active_users /* : Vec<User> */ = User::scan(client)
.filter(Condition::eq("role", "instructor"))
.all()
.await?;Raw SDK equivalent:
// Manual pagination loop with:
// .filter_expression("#role = :role")
// .expression_attribute_names("#role", "role")
// .expression_attribute_values(":role", AttributeValue::S("instructor".to_owned()))
// ... plus ExclusiveStartKey handling across pagesScan all items and dispatch by type discriminator:
use dynamodb_facade::ScanRequest;
let items /* : Vec<Item<PlatformTable>> */ = ScanRequest::<PlatformTable>::new(client)
.all()
.await?;
for item in items {
match item.attribute::<ItemType>() {
Some("USER") => {
let user = User::from_item(item);
// ...
}
Some("ENROLLMENT") => {
let enrollment = Enrollment::from_item(item);
// ...
}
_ => { /* skip unknown types */ }
}
}Scan with a PK prefix filter:
let user_items /* : Vec<Item<PlatformTable>> */ = ScanRequest::<PlatformTable>::new(client)
.filter(Condition::begins_with(PK::NAME, "USER#"))
.all()
.await?;Condition::eq("role", "admin") // role = :val
Condition::ne("status", "archived") // status <> :val
Condition::lt("progress", 0.5) // progress < :val
Condition::le("credits", 100) // credits <= :val
Condition::gt("score", 80) // score > :val
Condition::ge("enrolled_at", cutoff_ts) // enrolled_at >= :val// Attribute-level:
Condition::exists("email") // attribute_exists(email)
Condition::not_exists("deleted_at") // attribute_not_exists(deleted_at)
// Item-level (PK existence based on the TableDefinition's PK):
User::exists() // attribute_exists(PK)
User::not_exists() // attribute_not_exists(PK)Condition::begins_with(PK::NAME, "USER#") // begins_with(PK, :prefix)
Condition::contains("tags", "rust") // contains(tags, :val)Condition::between("score", 60, 100) // score BETWEEN :low AND :high
Condition::is_in("role", ["admin", "instructor"]) // role IN (:v1, :v2)Compare the size of an attribute (string length, list length, etc.):
Condition::size_gt("tags", 0) // size(tags) > 0
Condition::size_ge("email", 5) // size(email) >= 5
Condition::size_lt("content", 1000) // size(content) < 1000// AND via &
let cond = User::exists() & Condition::eq("role", "student");
// OR via |
let cond = User::not_exists() | Condition::lt(Expiration::NAME, now_ts);
// NOT via !
let cond = !Condition::eq("status", "archived");
// Complex composition:
let cond = User::exists()
& (Condition::not_exists("email")
| (Condition::exists("email") & Condition::not_exists("email_verified")));Combine a collection of conditions:
let cond = Condition::and([
Condition::eq("status", "draft"),
Condition::size_cmp("content", Comparison::Gt, 0),
Condition::size_cmp("title", Comparison::Gt, 0),
Condition::exists("author_id"),
]);Update::set("name", "Alice") // SET #name = :val
Update::set("score", 95.5) // SET #score = :val
Update::set("verified", true) // SET #verified = :val
Update::remove("temporary_field") // REMOVE #temporary_field
Update::remove("tags[2]") // REMOVE #tags[2]Update::increment("view_count", 1) // SET #view_count = #view_count + :val
Update::decrement("credits", 10) // SET #credits = #credits - :valSafely initialize-and-increment (no prior value required):
// SET #count = if_not_exists(#count, :zero) + :one
Update::init_increment("enrollment_count", 0, 1)
// SET #balance = if_not_exists(#balance, :initial) - :amount
Update::init_decrement("balance", 1000, 50)Advanced SET expressions:
use dynamodb_facade::UpdateSetRhs;
// SET old_score = if_not_exists(score, 0)
Update::set_custom("old_score", UpdateSetRhs::if_not_exists("score", 0))
// SET score = other_score + bonus
Update::set_custom("score", UpdateSetRhs::attr("other_score") + UpdateSetRhs::value(10))
// SET score = base_score - penalty
Update::set_custom("score", UpdateSetRhs::attr("base_score") - UpdateSetRhs::value(5))Update::list_append("tags", to_attribute_value(&["new_tag"]))
Update::list_prepend("notifications", to_attribute_value(&[notification]))For DynamoDB Set types (SS, NS, BS):
Update::add("tag_set", AsSet(vec!["rust".to_owned()]).into_attribute_value())
Update::delete("tag_set", AsSet(vec!["old_tag".to_owned()]).into_attribute_value())// Chain two updates:
let update = Update::set("name", "Alice")
.and(Update::set("email", "alice@example.com"));
// Combine from iterator (panics if empty):
let update = Update::combine([
name.map(|n| Update::set("name", n)),
email.map(|e| Update::set("email", e)),
should_clear_bio.then(|| Update::remove("bio")),
].into_iter().flatten());
// Safe version — returns None if iterator is empty:
let update: Option<Update<'_>> = Update::try_combine([
name.map(|n| Update::set("name", n)),
].into_iter().flatten());Key conditions restrict query results based on partition key and (optionally) sort key:
use dynamodb_facade::KeyCondition;
// PK only (for composite-key tables/indexes, returns all SK values):
KeyCondition::pk(user_id_string)
// PK + SK exact match:
KeyCondition::pk(user_id_string).sk_eq("USER")
// PK + SK prefix:
KeyCondition::pk(user_id_string).sk_begins_with("ENROLL#")
// PK + SK range:
KeyCondition::pk(user_id_string).sk_between("ASSIGN#A", "ASSIGN#Z")
// PK + SK comparison:
KeyCondition::pk(user_id_string).sk_gt("ENROLL#2024-01-01")
KeyCondition::pk(user_id_string).sk_le("ASSIGN#999")
// Generated from item type (uses HasAttribute<PK> to build the PK value):
Enrollment::key_condition(user_id)
Enrollment::key_condition(user_id).sk_begins_with("ENROLL#2024")
// Works for indexes too:
User::index_key_condition::<EmailIndex>(user_email)Typestate prevents calling SK methods on simple-key schemas at compile time.
// Will fail to compile because EmailIndex have no SortKey:
User::index_key_condition::<EmailIndex>(user_email).sk_begins_with("EMAIL#")use dynamodb_facade::{DynamoDBItemBatchOp, dynamodb_batch_write};
let requests: Vec<_> = new_enrollments.iter()
.map(|e| e.batch_put())
.collect();
dynamodb_batch_write::<PlatformTable>(client, requests).await?;dynamodb_batch_write automatically chunks into 25-item batches, runs in
parallel, and retries unprocessed items (up to 5 times with backoff).
let requests: Vec<_> = enrollments.iter()
.map(|e| e.batch_delete())
.collect();
dynamodb_batch_write::<PlatformTable>(client, requests).await?;Or by ID without loading the item:
let requests: Vec<_> = enrollment_keys.iter()
.map(|key_id| Enrollment::batch_delete_by_id(key_id))
.collect();
dynamodb_batch_write::<PlatformTable>(client, requests).await?;use dynamodb_facade::batch_delete;
let requests: Vec<_> = items.into_iter()
.filter_map(|item| match item.attribute::<ItemType>() {
Some("USER") => {
let mut user = User::from_item(item);
user.login_count = 0;
Some(user.batch_put())
}
Some("ENROLLMENT") => Some(batch_delete(item.into_key_only())),
_ => None,
})
.collect();
dynamodb_batch_write::<PlatformTable>(client, requests).await?;Raw SDK equivalent (batch write with 25-item chunking + retry):
// ~50 lines: manual WriteRequest::builder().put_request(...) / .delete_request(...),
// chunks(25), tokio::spawn per chunk, UnprocessedItems retry loopTransactions use the native aws_sdk_dynamodb transact_write_items() builder,
but facade types generate each TransactWriteItem with type-safe conditions.
Create an enrollment and atomically increment user's enrollment count:
use dynamodb_facade::DynamoDBItemTransactOp;
client
.transact_write_items()
.transact_items(
enrollment.transact_put()
.not_exists()
.build(),
)
.transact_items(
User::transact_update_by_id(
KeyId::pk(user_id),
Update::init_increment("enrollment_count", 0, 1),
)
.condition(
User::exists()
& (Condition::not_exists("enrollment_count")
| Condition::lt("enrollment_count", max_enrollments)),
)
.build(),
)
.send()
.await?;Remove an enrollment and decrement user's count:
client
.transact_write_items()
.transact_items(
Enrollment::transact_delete_by_id(KeyId::pk(user_id).sk(course_id))
.exists()
.build(),
)
.transact_items(
User::transact_update_by_id(
KeyId::pk(user_id),
Update::decrement("enrollment_count", 1),
)
.condition(Condition::exists("enrollment_count"))
.build(),
)
.send()
.await?;Atomically replace one enrollment with another:
client
.transact_write_items()
.transact_items(
old_enrollment.transact_delete()
.condition(
Enrollment::exists()
& Condition::not_exists("completed_at"),
)
.build(),
)
.transact_items(
new_enrollment.transact_put()
.not_exists()
.build(),
)
.send()
.await?;Include a pure condition check (no mutation) in a transaction:
client
.transact_write_items()
.transact_items(
user.transact_condition(
User::exists() & Condition::eq("role", "admin"),
)
.build(),
)
.transact_items(
dangerous_operation.transact_put()
.not_exists()
.build(),
)
.send()
.await?;let transaction = client.transact_write_items();
let transaction = transaction.transact_items(
user.transact_update(Update::combine([
Update::set("balance", new_balance),
Update::increment("purchase_count", 1),
Update::remove("pending_order"),
]))
.condition(
Condition::eq("secret", secret)
& Condition::eq("balance", old_balance)
& Condition::exists("pending_order"),
)
.build(),
);
let transaction = transaction.transact_items(
course.transact_update(Update::increment("enrollment_count", 1))
.exists()
.build(),
);
transaction.send().await?;The facade provides a unified Error enum:
use dynamodb_facade::{DynamoDBError, Error};
match result {
Ok(user) => { /* success */ }
Err(error) => {
// Downcast to underlying SDK error:
if let Some(DynamoDBError::ConditionalCheckFailedException(_)) =
error.as_dynamodb_error()
{
// Handle conflict (e.g. item already exists)
}
// Match on facade error variants:
match error {
Error::DynamoDB(boxed_err) => { /* AWS SDK error */ }
Error::Serde(serde_err) => { /* (de)serialization failure */ }
Error::Custom(msg) => { /* custom string error */ }
Error::Other(boxed_err) => { /* any other error */ }
}
}
}Create custom errors:
Error::custom("Invalid enrollment state")
Error::other(some_std_error)Item<TD> provides type-safe attribute access and key manipulation.
It is designed to always be a valid item for the table schema:
let item: Item<PlatformTable> = /* from any request `.raw()` */;
// Type-safe attribute extraction (returns typed reference):
let item_type: Option<&str> = item.attribute::<ItemType>();
let pk_value: &str = item.pk(); // always present
let sk_value: &str = item.sk(); // only available for CompositeKeySchema, and always present in that case
// Convert to typed struct:
let user = User::from_item(item); // panics on schema mismatch
let user = User::try_from_item(item)?; // returns Result
// Key manipulation:
let (key, remaining_attrs) = item.extract_key();
let key_only: Key<PlatformTable> = item.into_key_only();
let reconstructed = Item::from_key_and_attributes(key, extra_attrs);
// Custom item construction:
let minimal = Item::minimal_from(&my_item); // only key + additional attributes defined by the DynamoDBItem trait
let enriched = minimal.with_attributes([
("extra_field".to_owned(), some_value.into_attribute_value()),
]);
// Raw read-only access (Deref to HashMap<String, AttributeValue>):
let raw_av = item.get("some_field");Every operation builder uses compile-time typestates to prevent misuse. Here is a visual summary:
PutItemRequest:
put(client) → PutItemRequest<Typed, ReturnNothing, NoCondition>
.not_exists() / .condition(cond) → ...<..., AlreadyHasCondition> (one-shot)
.return_old() → ...<..., Return<Old>, ...>
.return_none() → ...<..., ReturnNothing, ...> (from Return<Old>)
.raw() → ...<Raw, ...> (one-way)
.await / .execute() → terminal
GetItemRequest:
get(client, key_id) → GetItemRequest<Typed, NoProjection>
.raw() → ...<Raw, ...>
.project(projection) → ...<Raw, AlreadyHasProjection> (forces Raw)
.consistent_read() → self
.await / .execute() → terminal
UpdateItemRequest:
update_by_id(client, key_id, update) → UpdateItemRequest<Typed, Return<New>, NoCondition>
.exists() / .condition(cond) → ...<..., AlreadyHasCondition>
.return_old() → ...<..., Return<Old>, ...>
.return_new() → ...<..., Return<New>, ...>
.return_none() → ...<..., ReturnNothing, ...>
.raw() → ...<Raw, ...>
.await / .execute() → terminal
QueryRequest / ScanRequest:
query(client, key_cond) → QueryRequest<Typed, NoFilter, NoProjection>
.filter(cond) → ...<..., AlreadyHasFilter, ...>
.project(projection) → ...<Raw, ..., AlreadyHasProjection>
.limit(n) / .scan_index_forward(b) → self
.all() → Vec<T> or Vec<Item<TD>>
.stream() → impl Stream<Item=Result<T>> or ...Item<TD>>
TransactWriteItem builders:
transact_put() / transact_delete() / transact_update()
.condition(cond) / .exists() / .not_exists() → ...<AlreadyHasCondition>
.build() → TransactWriteItem
Key invariant: calling .condition() twice, or .filter() twice, is a
compile-time error — the typestate transitions consume the NoCondition /
NoFilter marker and produce AlreadyHasCondition / AlreadyHasFilter, which
does not have the .condition() / .filter() method.
| Concern | Raw aws-sdk-dynamodb |
dynamodb-facade |
|---|---|---|
| Key construction | Manual HashMap<String, AV>, format strings |
KeyId::pk(id).sk(id), type-checked |
| Expressions | Raw strings ("SET #n = :v"), separate name/value maps |
Update::set("n", v), auto-managed placeholders |
| Conditions | String concatenation, manual :placeholder tracking |
Condition::eq(...), & / | operators |
| Serialization | Manual serde_dynamo calls, AttributeValue::S(...) |
Automatic via DynamoDBItem trait |
| Pagination | Hand-written ExclusiveStartKey loops |
.all() auto-paginates, .stream() for lazy |
| Batch writes | Manual 25-item chunking, retry loop | dynamodb_batch_write() handles everything |
| Transactions | Raw TransactWriteItem::builder() |
.transact_put().condition(...).build() |
| Type safety | Runtime errors on wrong key/expression | Compile-time typestate enforcement |
| Duplicate calls | Silent runtime bugs | .condition() twice = compile error |
| Single-table | No built-in support for type dispatch | item.attribute::<ItemType>() + T::from_item() |