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, a put reads the item first, then writes a DynamoDB TransactWriteItems that atomically writes the main item and a sentinel item for each unique value it claims. Over an existing item, only changed values get a new sentinel, and the old one is released. 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, whether a put or an update wrote it. 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 creates a new sentinel and removes the old one — if this item still owns it (if its reservation expired and another item has claimed the value since, that item’s sentinel is left alone). 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.layerConfig({ region: Config.succeed("us-east-1"), endpoint: Config.String("DYNAMODB_ENDPOINT").pipe(Config.withDefault("http://localhost:8000")), credentials: Config.succeed({ accessKeyId: "local", secretAccessKey: "local" }), }), MainTable.layer({ name: "unique-demo-table" }),)
const main = program.pipe(Effect.provide(AppLayer))
Effect.runPromise(main).then(() => console.log("\nDone."))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