Skip to content

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 UniqueConstraintViolation errors
  • TTL-based idempotency key pattern
  • Sentinel rotation on update (old value released, new value claimed)

Two pure domain models — a User with email and username, and an ApiRequest with an idempotency key:

models.ts
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 const
const 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.


entities.ts
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:

entities.ts
// Users with two unique constraints:
// - email: globally unique email addresses
// - username: globally unique usernames
const 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:

entities.ts
// 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.


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: Bob

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",
})

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"

The complete runnable example is at examples/unique-constraints.ts in the repository.

Terminal window
docker run -d -p 8000:8000 amazon/dynamodb-local
Terminal window
npx tsx examples/unique-constraints.ts
main.ts
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),
)

ConceptHow it’s used
Unique constraintsunique: { email: ["email"], username: ["username"] } on Entity config
Sentinel itemsAutomatically created/deleted via DynamoDB transactions — no manual management
UniqueConstraintViolationTagged 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 rotationUpdating a unique field atomically releases the old value and claims the new one
Sparse constraintsConstraints on optional fields skip the sentinel when any composite is unset — see Data Integrity → Sparse Constraints
No extra indexesSentinels live in the same table using structured key values — no GSIs needed