dynamodb-facade 0.1.1

A typed facade over aws-sdk-dynamodb with expression builders and batch/transaction support
Documentation

crates.io docs.rs CI License MSRV

dynamodb-facade

A typed facade over aws-sdk-dynamodb that replaces string-spliced expressions, hand-built key maps, pagination loops, and 25-item batch chunking with composable, compile-time-checked Rust.

Pre-1.0. The API is stabilising but may still change between minor versions until 1.0.


Why this crate

The AWS SDK for DynamoDB is correct and complete, but writing even a simple conditional update means juggling:

  • an expression_attribute_names map,
  • an expression_attribute_values map,
  • the expression string that references both,
  • a manual HashMap<String, AttributeValue> key, and
  • serde_dynamo calls.

dynamodb-facade takes that surface and moves as much of it as possible into the type system and internal machinery. None of the expression wiring is visible in user code, and several whole categories of bug (duplicate .condition(), wrong sort key on a simple-key index, Return<Old> deserialisation mismatch) become compile errors.

Raw SDK vs. facade — a conditional update

Raw aws-sdk-dynamodb:

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))
    .expect("asked for ALL_NEW")?;

Same operation with dynamodb-facade:

User::update_by_id(
    client,
    KeyId::pk(user_id),
    Update::set("name", new_name),
)
.exists()
.await?;
// Returns the updated `User`. Placeholders, key map,
// return-value plumbing, and deserialisation are all handled.

The same compression applies across every operation. A 50-line raw batch-write loop with manual 25-item chunking, parallel dispatch, and UnprocessedItems retry becomes a single call to dynamodb_batch_write. Hand-rolled ExclusiveStartKey pagination becomes .all() or .stream().



Features

  • Expression builders, not strings. Condition::eq, Update::set, KeyCondition::pk(...).sk_begins_with(...) — combined with &, |, !, .and(), .combine(). The library manages every #name / :value placeholder for you.
  • Zero-sized schema types. attribute_definitions!, table_definitions!, index_definitions! generate marker types that encode the table/index key shape. Using for a sort key on a simple-key table/index is a compile error.
  • Typestate operation builders. .condition() twice? Compile error. .sk_begins_with() on a PK-only index? Compile error. .projection() and attempt using deserialized type? Compile error.
  • Single-table friendly. Explicit first-class support for the mono-table pattern with PK/SK + type discriminator, including typed and untyped dispatch on scan/query results.
  • Automatic pagination. .all() collects, .stream() yields an impl Stream<Item = Result<T>>. No ExclusiveStartKey bookkeeping.
  • Automatic batch chunking + retry. dynamodb_batch_write splits into 25-item batches, runs them in parallel, and retries UnprocessedItems with backoff (up to 5 attempts).
  • Typed transactions. transact_put, transact_delete, transact_update, transact_condition plug straight into the SDK's transact_write_items() builder; each TransactWriteItem is built with the same condition DSL as a stand-alone operation.
  • Escape hatch preserved. Every builder exposes .into_inner() returning the underlying SDK fluent builder, so nothing the raw SDK can do is locked out.
  • Flexible serialisation. Items round-trip through serde_dynamo by default; DynamoDBItem can be hand-implemented when serde is not a good fit.

Getting Started

Prerequisites

  • Rust 1.85.0 or later (edition 2024).
  • An AWS account and a DynamoDB table, or DynamoDB Local via Docker for development.

Installation

cargo add dynamodb-facade

Or in Cargo.toml:

[dependencies]
dynamodb-facade = "0.1"

Quick Start

Declare the attributes, the table, wire up a struct, perform operations.

use dynamodb_facade::{
    attribute_definitions, table_definitions, dynamodb_item,
    Condition, Update, KeyId, DynamoDBItemOp, StringAttribute,
};
use serde::{Deserialize, Serialize};

// 1. Attribute tokens (zero-sized types).
attribute_definitions! {
    PK       { "PK": StringAttribute }
    SK       { "SK": StringAttribute }
    ItemType { "_TYPE": StringAttribute }
}

// 2. Table definition.
table_definitions! {
    PlatformTable {
        type PartitionKey = PK;
        type SortKey = SK;
        fn table_name() -> String {
            std::env::var("TABLE_NAME").unwrap_or("my_table".to_owned())
        }
    }
}

// 3. Item type wired to the table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
    pub id: String,
    pub name: String,
    pub email: String,
}

dynamodb_item! {
    #[table = PlatformTable]
    User {
        #[partition_key]
        PK {
            fn attribute_id(&self) -> &'id str { &self.id }
            fn attribute_value(id) -> String { format!("USER#{id}") }
        }
        #[sort_key]
        SK { const VALUE: &'static str = "USER"; }
        ItemType { const VALUE: &'static str = "USER"; }
    }
}

// 4. CRUD — no boilerplate.
# async fn example(client: dynamodb_facade::Client) -> dynamodb_facade::Result<()> {
let user = User {
    id: "u-1".to_owned(),
    name: "Alice".to_owned(),
    email: "alice@example.com".to_owned(),
};

// Create or overwrite:
user.put(client.clone()).await?;

// Create-only (fails if the item already exists):
user.put(client.clone()).not_exists().await?;

// Get by id:
let loaded /* : Option<User> */ = User::get(client.clone(), KeyId::pk("u-1")).await?;

// Conditional update, returning the new item:
let updated /* : User */ = User::update_by_id(
    client.clone(),
    KeyId::pk("u-1"),
    Update::set("name", "Alicia"),
)
.exists()
.await?;

// Delete by id, returning the old item:
let deleted /* : Option<User> */ = User::delete_by_id(client, KeyId::pk("u-1")).await?;
# Ok(())
# }

More Examples

The following are small, representative slices. For a full tour across a single-table domain (users, courses, enrollments, configs), see EXAMPLES.md — 13 sections covering schema design, every CRUD variant, queries on indexes, scans with dispatch, the full condition and update DSL, batch writes, and transactions.

Composable conditions

// Attribute-level:
let c = Condition::exists("email") & Condition::not_exists("deleted_at");

// Item-level (uses the table's PK attribute automatically):
let c = User::exists();

// Variadic AND:
let c = Condition::and([
    Condition::eq("status", "draft"),
    Condition::size_gt("content", 0),
    Condition::exists("author_id"),
]);

// OR / NOT:
let c = User::not_exists() | Condition::lt(Expiration::NAME, now_ts);
let c = !Condition::eq("status", "archived");

Composable updates

// Chain:
let u = Update::set("name", "Alice")
    .and(Update::remove("legacy_field"));

// Atomic counters:
let u = Update::increment("login_count", 1);
let u = Update::init_increment("enrollment_count", 0, 1); // if_not_exists -> init to 0 + increment

// Merge a variable number of optional updates into a single expression:
let u = 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(),
);

Query with automatic pagination

// All enrollments for a user — key condition derived from the item type:
let enrollments /* : Vec<Enrollment> */ =
    Enrollment::query(client.clone(), Enrollment::key_condition(user_id))
    .all()
    .await?;

// Query a GSI:
let users_by_email /* : Vec<User> */ =
    User::query_index::<EmailIndex>(
        client.clone(),
        KeyCondition::pk(email_address),
    )
    .all()
    .await?;

// Stream instead of collect:
let mut stream = User::scan(client.clone())
    .filter(Condition::eq("role", "instructor"))
    .stream();
while let Some(user) = stream.try_next().await? { /* ... */ }

Batch writes

let requests: Vec<_> = enrollments.iter().map(|e| e.batch_put()).collect();
// Chunks into 25-item batches, runs them in parallel,
// and retries UnprocessedItems with backoff.
dynamodb_batch_write::<PlatformTable>(client, requests).await?;

Transactions

// Atomically create an enrollment and increment the user's enrollment count:
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::lt("enrollment_count", max_enrollments),
        )
        .build(),
    )
    .send()
    .await?;

Compile-time safety in action

// This does not compile — EmailIndex has no sort key:
User::index_key_condition::<EmailIndex>(email).sk_begins_with("EMAIL#");

// This does not compile — .condition() twice consumes the NoCondition typestate:
user.put(client)
    .condition(some_cond)
    .condition(other_cond); // error: no method `.condition` on AlreadyHasCondition

FAQ

Why a facade, not a #[derive(DynamoDBItem)] proc macro? Declarative macros (dynamodb_item!, table_definitions!, attribute_definitions!) cover today's surface with straightforward, readable expansions. A derive-style proc macro may be on the roadmap but is deliberately not the first deliverable: the declarative form keeps compile times low-ish, stays ergonomic for the common cases, and leaves room for the proc macro to reuse the same underlying traits without locking down the design.

Is single-table design required? No. The crate has first-class support for the mono-table pattern (PK + SK with a type discriminator) because that's the author's main use-case, but nothing in the API assumes it. Simple-key tables, multiple tables, and the same struct serialised to different tables (useful for migrations) are all supported.

Can I drop down to the raw AWS SDK when I need to? Yes. Every builder has an .into_inner() method returning the underlying aws_sdk_dynamodb fluent builder, and the crate re-exports aws_sdk_dynamodb::{Client, Error as DynamoDBError, types::AttributeValue} so you do not need to pin the SDK version separately.

How are conditional-check failures surfaced? As Error::DynamoDB(ConditionalCheckFailedException(_)). Use error.as_dynamodb_error() to downcast and match on specific SDK error types. See the error-handling example in crate docs.

Does it work on AWS Lambda / inside async runtimes? Yes — the crate builds on tokio and futures, identical to aws-sdk-dynamodb itself. It adds no runtime of its own.


Roadmap

Publicly tracked on the issue tracker. The larger items currently planned:

  • API stabilisation toward 1.0 — minor breaking changes are still possible in the 0.x line while the API settles.

You have a suggestion? Please do send an issue my way!


Minimum Supported Rust Version

This crate requires Rust 1.85.0 or later (edition 2024). MSRV changes will be treated as a minor version bump until 1.0, and as a breaking change after.


Contributing

We welcome bug reports, feature requests, and pull requests. See CONTRIBUTING.md for the full guide.

For PRs — in short:

  1. Enter a Nix + direnv shell (installs the toolchain and pre-commit hooks automatically), or run ./scripts/install-hooks.sh manually.
  2. Make your change.
  3. The pre-commit hook runs the same four checks as CI: cargo fmt --check, cargo clippy --all-targets --all-features -- -D warnings, RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps --document-private-items, and cargo test --all-features.
  4. Open a PR. If the pre-commit hook passes locally, CI will pass.

CI runs three parallel jobs: Lint and Test on stable, MSRV Check on 1.85.0.


License

Distributed under the MIT License. See LICENSE for the full text.


Acknowledgments


Authors

If you find this crate useful, please star the repository and share your feedback!