Example: Unique Constraints
Unique constraints guarantee that certain field values (like email addresses or usernames) are globally unique across all items of an entity. effect-dynamodb enforces this atomically using DynamoDB transaction sentinel items — no external locks or secondary queries required.
What you’ll learn:
- Declaring unique constraints on an entity
- Automatic enforcement via sentinel items
- Handling
UniqueConstraintViolationerrors - TTL-based idempotency key pattern
- Sentinel rotation on update (old value released, new value claimed)
Step 1: Models
Section titled “Step 1: Models”Two pure domain models — a User with email and username, and an ApiRequest with an idempotency key:
class User extends Schema.Class<User>("User")({ userId: Schema.String, email: Schema.String, username: Schema.NonEmptyString, tenantId: Schema.String, name: Schema.NonEmptyString,}) {}
const RequestStatus = { Pending: "pending", Completed: "completed", Failed: "failed" } as constconst RequestStatusSchema = Schema.Literals(Object.values(RequestStatus))
class ApiRequest extends Schema.Class<ApiRequest>("ApiRequest")({ requestId: Schema.String, idempotencyKey: Schema.String, payload: Schema.String, status: RequestStatusSchema,}) {}No DynamoDB concepts here — uniqueness is declared at the entity level, not in the model.
Step 2: Schema, Table, and Entities
Section titled “Step 2: Schema, Table, and Entities”Schema and Table
Section titled “Schema and Table”import { DynamoSchema, Table } from "effect-dynamodb"
const AppSchema = DynamoSchema.make({ name: "unique-demo", version: 1 })const MainTable = Table.make({ schema: AppSchema, entities: { Users, ApiRequests } })Users Entity — Multiple Unique Constraints
Section titled “Users Entity — Multiple Unique Constraints”The unique config declares which fields must be globally unique. Each key names the constraint; the value lists the fields that compose the unique value:
// Users with two unique constraints:// - email: globally unique email addresses// - username: globally unique usernamesconst Users = Entity.make({ model: User, entityType: "User", primaryKey: { pk: { field: "pk", composite: ["userId"] }, sk: { field: "sk", composite: [] }, }, timestamps: true, unique: { email: ["email"], username: ["username"], },})Under the hood, every put becomes a DynamoDB TransactWriteItems that atomically writes the main item and a sentinel item for each unique constraint. If any sentinel already exists, the transaction fails with UniqueConstraintViolation.
ApiRequests Entity — TTL-Based Idempotency Key
Section titled “ApiRequests Entity — TTL-Based Idempotency Key”For idempotency keys, you want the constraint to expire after a window. Pass an object with fields and ttl instead of a plain array:
// API requests with a TTL-based idempotency key constraint.// The sentinel item auto-expires after the TTL, allowing the same// idempotency key to be reused later.const ApiRequests = Entity.make({ model: ApiRequest, entityType: "ApiRequest", primaryKey: { pk: { field: "pk", composite: ["requestId"] }, sk: { field: "sk", composite: [] }, }, timestamps: true, unique: { idempotencyKey: { fields: ["idempotencyKey"], ttl: Duration.minutes(30), }, },})The sentinel item gets a _ttl attribute set to 30 minutes in the future. DynamoDB’s TTL mechanism automatically deletes it after expiry, freeing the idempotency key for reuse.
Step 3: Operations
Section titled “Step 3: Operations”Creating Users — Duplicate Detection
Section titled “Creating Users — Duplicate Detection”Create the first user normally. Attempting to create a second user with the same email is blocked:
// #region create-user const alice = yield* db.entities.Users.put({ userId: "u-1", email: "alice@example.com", username: "alice", tenantId: "t-1", name: "Alice", })
// Same email → UniqueConstraintViolation const duplicateEmail = yield* db.entities.Users.put({ userId: "u-2", email: "alice@example.com", // Same email as Alice! username: "bob", tenantId: "t-1", name: "Bob", }) .asEffect() .pipe( Effect.catchTag("UniqueConstraintViolation", (e) => Effect.succeed(`Blocked: constraint="${e.constraint}", fields=${JSON.stringify(e.fields)}`), ), ) // Blocked: constraint="email", fields={"email":"alice@example.com"}The error includes the constraint name and the conflicting field values, making it straightforward to produce user-facing messages.
Username uniqueness works identically — same email but duplicate username is also blocked:
// #region duplicate-username // Different email, same username → UniqueConstraintViolation const bob = yield* db.entities.Users.put({ userId: "u-2", email: "bob@example.com", // Different email — OK username: "alice", // Same username as Alice! tenantId: "t-1", name: "Bob", }) .asEffect() .pipe( Effect.catchTag("UniqueConstraintViolation", (e) => Effect.succeed(`Blocked: constraint="${e.constraint}"`), ), ) // Blocked: constraint="username"
// Both different → succeeds const charlie = yield* db.entities.Users.put({ userId: "u-2", email: "bob@example.com", username: "bob", tenantId: "t-1", name: "Bob", }) // Created: BobIdempotency Key Pattern
Section titled “Idempotency Key Pattern”The TTL-based constraint prevents duplicate request processing. The first request succeeds; retries with the same key are blocked until the sentinel expires:
// #region idempotency // First request — succeeds const req1 = yield* db.entities.ApiRequests.put({ requestId: "r-1", idempotencyKey: "idem-abc-123", payload: '{"action":"charge","amount":100}', status: "completed", })
// Retry with same idempotency key → blocked const retry = yield* db.entities.ApiRequests.put({ requestId: "r-2", idempotencyKey: "idem-abc-123", // Same idempotency key! payload: '{"action":"charge","amount":100}', status: "pending", }) .asEffect() .pipe( Effect.catchTag("UniqueConstraintViolation", (e) => Effect.succeed(`Blocked: constraint="${e.constraint}" — duplicate request prevented`), ), ) // The sentinel has a TTL of 30 minutes // After expiry, the same key can be reused
// Different idempotency key — succeeds immediately const req2 = yield* db.entities.ApiRequests.put({ requestId: "r-2", idempotencyKey: "idem-def-456", payload: '{"action":"refund","amount":50}', status: "completed", })Sentinel Rotation on Update
Section titled “Sentinel Rotation on Update”When you update a unique field, effect-dynamodb atomically removes the old sentinel and creates a new one. The old value becomes available for other items:
// #region sentinel-rotation // Update Alice's email yield* db.entities.Users.update({ userId: "u-1" }).set({ email: "alice-new@example.com" })
// The old email "alice@example.com" is now free const newUser = yield* db.entities.Users.put({ userId: "u-3", email: "alice@example.com", // Previously Alice's — now available username: "charlie", tenantId: "t-1", name: "Charlie", }) // Created: Charlie
// But Alice's new email is still protected const conflict = yield* db.entities.Users.update({ userId: "u-2" }) .set({ email: "alice-new@example.com" }) .asEffect() .pipe( Effect.catchTag("UniqueConstraintViolation", (e) => Effect.succeed(`Blocked: constraint="${e.constraint}"`), ), ) // Blocked: constraint="email"Running the Example
Section titled “Running the Example”The complete runnable example is at examples/unique-constraints.ts in the repository.
docker run -d -p 8000:8000 amazon/dynamodb-localnpx tsx examples/unique-constraints.tsLayer Setup
Section titled “Layer Setup”const AppLayer = Layer.mergeAll( DynamoClient.layer({ region: "us-east-1", endpoint: "http://localhost:8000", credentials: { accessKeyId: "local", secretAccessKey: "local" }, }), MainTable.layer({ name: "unique-demo-table" }),)
const main = program.pipe(Effect.provide(AppLayer))
Effect.runPromise(main).then( () => console.log("\nDone."), (err) => console.error("\nFailed:", err),)Key Takeaways
Section titled “Key Takeaways”| Concept | How it’s used |
|---|---|
| Unique constraints | unique: { email: ["email"], username: ["username"] } on Entity config |
| Sentinel items | Automatically created/deleted via DynamoDB transactions — no manual management |
| UniqueConstraintViolation | Tagged error with constraint name and fields for precise error handling |
| TTL-based uniqueness | { fields: [...], ttl: Duration.minutes(30) } for expiring constraints like idempotency keys |
| Sentinel rotation | Updating a unique field atomically releases the old value and claims the new one |
| Sparse constraints | Constraints on optional fields skip the sentinel when any composite is unset — see Data Integrity → Sparse Constraints |
| No extra indexes | Sentinels live in the same table using structured key values — no GSIs needed |
What’s Next?
Section titled “What’s Next?”- Modeling Guide — Deep dive into models, schemas, tables, and entities
- Data Integrity — Unique constraints, optimistic locking, and conditional writes
- Example: Human Resources — Single-table design with collections, transactions, and soft delete