Skip to content

Modeling

This guide covers how to define domain models, application schemas, tables, and entities in effect-dynamodb. These four constructs form the foundation of every application.

Models use standard Effect Schema definitions — Schema.Class for class instances or Schema.Struct for plain objects. They contain only domain fields — no DynamoDB concepts, no key composition, no timestamps.

class Employee extends Schema.Class<Employee>("Employee")({
employeeId: Schema.String.pipe(Schema.brand("EmployeeId")),
tenantId: Schema.String.pipe(Schema.brand("TenantId")),
email: Schema.String,
displayName: Schema.NonEmptyString,
department: Schema.String,
// Self schema — wire format chosen by the storage override below
// (transforms paired with `storedAs` are rejected at Entity.make time).
hireDate: Schema.DateTimeUtc,
createdBy: Schema.String,
}) {}

Models are portable. The same Employee schema can be used with DynamoDB, SQL, API responses, or any other storage backend. Branded types like Schema.brand("EmployeeId") prevent accidentally mixing up identifiers at compile time.

DynamoModel.configure layers DynamoDB-specific overrides onto a pure domain model. The model stays clean — infrastructure details like field renaming, storage encoding, and immutability are declared separately.

const EmployeeModel = DynamoModel.configure(Employee, {
createdBy: { immutable: true },
hireDate: { field: "hd", storedAs: DynamoModel.DateEpochSeconds },
displayName: { field: "dn" },
employeeId: { identifier: true },
})

Pass the configured model to Entity.make({ model: EmployeeModel, ... }). The Entity reads these overrides when building derived schemas and DynamoDB operations.

Each field accepts an AttributeConfig object with these options:

OptionTypeDescription
fieldstringRename the domain field to a different DynamoDB attribute name
storedAsDynamoModel.* date schema, DynamoModel.SparseMap() or DynamoModel.Packed(...)Override how the field is stored: a date encoding (must match the domain type), or a storage layout (see Storage Layouts)
immutablebooleanExclude from Entity.Update — read-only after creation
identifierbooleanMark as the identity field for ref/aggregate resolution
refbooleanMark as a denormalized reference to another entity

Map domain field names to shorter or different DynamoDB attribute names. Useful for reducing storage costs or matching an existing table schema:

const EmployeeModel = DynamoModel.configure(Employee, {
displayName: { field: "dn" },
department: { field: "dept" },
})
// Domain: employee.displayName → DynamoDB attribute: "dn"

Override how a field is stored in DynamoDB. The domain type must match (enforced at compile time):

const EmployeeModel = DynamoModel.configure(Employee, {
// Domain: DateTime.Utc → stored as epoch seconds (for DynamoDB TTL)
hireDate: { storedAs: DynamoModel.DateEpochSeconds },
})

Available date schemas: DynamoModel.DateString (ISO), DynamoModel.DateEpochMs, DynamoModel.DateEpochSeconds, DynamoModel.DateTimeZoned. See Advanced for the full date schema reference.

Storage overrides only apply to self date schemas. If you declared a transform schema (e.g. Schema.DateTimeUtcFromString), that transform IS the wire format — storedAs and other DynamoEncoding annotations are rejected at Entity.make() time. The two configuration paths are mutually exclusive: either declare a self schema (Schema.DateTimeUtc) and let the annotation drive storage, OR declare a transform and own the wire format yourself.

PatternDomain typeWire format source
Self + annotationSchema.DateTimeUtcstoredAs (configure) or pipe(DynamoModel.storedAs(...)) annotation — string (default), epochMs, or epochSeconds
TransformSchema.DateTimeUtcFromString, DynamoModel.DateString, DynamoModel.DateEpochSeconds, …The transform itself — storedAs is rejected

Applying storedAs(DynamoModel.DateEpochSeconds) on top of Schema.DateTimeUtcFromString (which already encodes to a string) used to silently override the transform’s wire format. After the codec direction fix in v1.4, this combination raises a clear error at Entity.make() time. Migrate to a self schema (Schema.DateTimeUtc.pipe(DynamoModel.storedAs(...))) or drop the override.

A self date schema is stored in its wire form wherever it sits in the model: at the top level, in a Schema.Struct or Schema.Class, in an array, and also in a container such as these:

  • a Schema.NullOr or other union, such as Schema.NullOr(Schema.DateTimeUtc)
  • a nullable class, such as Schema.NullOr(Stamp), where Stamp has a date field
  • an array of a union, such as Schema.Array(Schema.NullOr(Schema.DateTimeUtc))
  • a Schema.Record value, such as Schema.Record(Schema.String, Schema.DateTimeUtc)
  • a Schema.Tuple, Schema.TupleWithRest or Schema.StructWithRest

A storedAs annotation applies there too, so Schema.NullOr(Schema.DateTimeUtc.pipe(DynamoModel.storedAs(DynamoModel.DateEpochMs))) stores a number. A DynamoModel.configure storedAs override on a union field applies to the union’s date member. If the union has more than one date member, the override can’t say which one it means, and Entity.make() rejects it with EDD-9057; annotate the intended member with .pipe(DynamoModel.storedAs(...)) instead. Transform schemas such as Schema.DateTimeUtcFromString already owned their wire form in every container and are unchanged.

A union is read back by trying its members, so a stored value must not be claimable by two of them.

  • A date stored as a string next to a string member. In Schema.Union([Schema.DateTimeUtc, Schema.String]), the date member claims only the exact ISO string the library writes for a date, and it is tried first. A string such as "2020", "5" or "hello" reads back as a string. A string that happens to be a canonical ISO instant, such as "2000-01-01T00:00:00.000Z", reads back as a DateTime. If a string field may legitimately hold ISO instants, use a tagged or discriminated shape instead of a bare union. The same rule applies through nested unions, such as Schema.Union([Schema.NullOr(Schema.DateTimeUtc), Schema.String]).
  • A date stored as a number next to a numeric member. A date stored as epoch milliseconds or seconds next to Schema.Number, a number literal, Schema.BigInt or another epoch date can’t be told apart from that member at all, so Entity.make() rejects it with EDD-9058. Store the date as a string (the default for a self date), or remove the numeric member. On an entity, Schema.NumberFromString and Schema.BigIntFromString are stored as strings, so they don’t collide. An aggregate also rejects those two, because it re-decodes domain values on update, where their domain value is a number (see Aggregates).

A zoned field, either DynamoModel.DateTimeZoned or a self Schema.DateTimeZoned, is stored as an ISO string with its zone, and read back with the same zone: a named zone as 2025-01-01T19:00:00.000+09:00[Asia/Tokyo], an offset zone as 2025-01-01T15:00:00.000+05:00. That holds wherever the field sits, on entities and aggregates. Versions up to 1.22.0 read an offset zone back as UTC. The stored form is unchanged.

Offsets that aren’t whole minutes, such as historical local-mean-time offsets or a sub-minute DateTime.zoneMakeOffset, are rounded to the minute by Effect’s ISO format, and the instant read back moves by the same amount. Earlier versions behaved the same way.

A .check() refinement on an array, a struct or a class built over a checked struct is enforced on every write when the container holds a date or another value the library substitutes, for example Schema.Array(Schema.DateTimeUtc).check(Schema.isMaxLength(1)). That covers put, create, update and .set(), path operations, .append(), Batch and Transaction. A violating value fails with a ValidationError. Earlier versions silently dropped these checks, so rows that break them may already exist. Reads don’t enforce them, so those rows still read, and the next write that sets the field to a valid value repairs them.

Two storedAs values change how a structured field is laid out on the item, rather than how one value is encoded:

storedAsValid onStored asUse when
(omitted)any fielda native map (M) or list (L)the default; DynamoDB can address paths inside it
DynamoModel.SparseMap()a Schema.Recordone top-level attribute per entry (metrics#2026-04)each key is updated, counted or checked on its own. See Sparse Maps.
DynamoModel.Packed({ format: "json", compression? })a struct, class, array, record or union of themone JSON string (S), or compressed binary (B) at or above a thresholdthe value is read and written whole, and you want faster bulk reads or fewer write units. See Packed Storage.
const OrderModel = DynamoModel.configure(Order, {
lines: { storedAs: DynamoModel.Packed({ format: "json" }) },
details: {
field: "d",
storedAs: DynamoModel.Packed({ format: "json", compression: { codec: "zstd" } }),
},
})

A packed field can’t be a key or index composite, a unique-constraint field or a ref, and DynamoDB can’t see inside it. Conditions and filters only test whether it exists, and updates replace it whole. Readers go by each item’s stored form, so turning packing on or off needs no data rewrite.

Mark fields as read-only after creation. Immutable fields are present in Entity.Input (you provide them on creation) and Entity.Record (you can read them), but excluded from Entity.Update:

const EmployeeModel = DynamoModel.configure(Employee, {
createdBy: { immutable: true },
})
// Entity.Update<E> will not include createdBy

Primary key composite attributes are inherently immutable — you don’t need to mark them.

An update may restate an immutable field with its current value, so spreading a whole record into .set() works. A .set() with a different value fails with a ValidationError, as does a .set() that changes a primary-key composite. Earlier versions silently ignored the latter.

identifier and ref support aggregate and reference resolution. See Aggregates for details.

const EmployeeModel = DynamoModel.configure(Employee, {
employeeId: { identifier: true }, // identity field for ref resolution
})

Options compose freely on a single field:

const EmployeeModel = DynamoModel.configure(Employee, {
createdBy: { immutable: true },
hireDate: { field: "hd", storedAs: DynamoModel.DateEpochSeconds },
displayName: { field: "dn" },
employeeId: { identifier: true },
})

DynamoSchema defines the application-level namespace that prefixes every generated key.

const AppSchema = DynamoSchema.make({
name: "projmgmt",
version: 1,
casing: "lowercase",
})
PropertyTypeDefaultDescription
namestringrequiredApplication name, used as key prefix
versionnumberrequiredSchema version for migration support
casing"lowercase" | "uppercase" | "preserve""lowercase"Casing for the whole composed key — labels and composite values (see Casing)

Schema versioning enables safe migrations. Items with $projmgmt#v1#... keys are completely isolated from $projmgmt#v2#... keys:

$projmgmt#v1#employee#employeeid_emp-alice ← current production
$projmgmt#v2#employee#employeeid_emp-alice ← new schema version (migration in progress)

This supports blue/green deployments and gradual rollback.

Multiple applications can share the same DynamoDB table with complete key isolation:

$projmgmt#v1#employee#employeeid_emp-alice ← Project Management app
$billing#v1#customer#customerid_cust-alice ← Billing app

Casing applies uniformly across the entire generated key: schema name, version prefix, entity type, collection name, composite attribute names, and composite attribute values. "Male" and "male" produce identical keys — this is important for case-insensitive lookups. The original attribute casing is preserved on the stored attribute value itself; only the composed key string is cased.

// With casing: "lowercase", entityType: "Employee", composite value "Emp-Alice"
// Key: $projmgmt#v1#employee#employeeid_emp-alice
// Stored attribute: employeeId = "Emp-Alice" (original casing preserved)

An index can override the schema’s casing with its own casing, and an event stream with makeStream({ casing }) — see Casing Resolution.

A few fixed markers inside keys are not cased, under any setting: the v in the version prefix ($APP#v1), the version-snapshot #v# and soft-delete #deleted# infixes, and the _1 in event and isolated sort keys. Under "uppercase" a version snapshot key is $APP#v1#EMPLOYEE#v#0000003. The time-series #e# infix is the exception — it follows the casing (#E# under "uppercase"). These are fixed because they are already stored; they will not change.

Table declares the physical DynamoDB table and registers the entities that share it.

const MainTable = Table.make({ schema: AppSchema, entities: { Employees } })

The Table owns “what exists physically.” The Entity owns “how I use it.” Multiple entities can share the same table (single-table design).

PropertyTypeDescription
schemaDynamoSchemaApplication schema for key prefixing
entitiesRecord<string, Entity>Named entities registered on this table
aggregatesRecord<string, Aggregate>Named aggregates registered on this table (optional)

The physical table name is provided at runtime via MainTable.layer({ name: "ProjectManagement" }). See Advanced for configuration details.

The Entity binds a model to a table. It defines:

  • Indexes — How model attributes compose into DynamoDB keys
  • System fields — Timestamps, versioning, soft delete
  • Unique constraints — Field-level uniqueness enforcement
  • Collections — Cross-entity query groups
const Employees = Entity.make({
model: EmployeeModel,
entityType: "Employee",
primaryKey: {
pk: { field: "pk", composite: ["employeeId"] },
sk: { field: "sk", composite: [] },
},
indexes: {
byTenant: {
name: "gsi1",
pk: { field: "gsi1pk", composite: ["tenantId"] },
sk: { field: "gsi1sk", composite: ["department", "hireDate"] },
type: "clustered",
},
byEmail: {
name: "gsi2",
pk: { field: "gsi2pk", composite: ["email"] },
sk: { field: "gsi2sk", composite: [] },
},
},
unique: { email: ["email"] },
timestamps: true,
versioned: { retain: true, ttl: Duration.days(90) },
softDelete: { ttl: Duration.days(30) },
})

The primaryKey defines how the entity’s partition key and sort key are composed from model attributes:

primaryKey: {
pk: {
field: "pk", // Physical DynamoDB attribute name
composite: ["employeeId"], // Model attributes that compose the partition key
},
sk: {
field: "sk",
composite: [], // Empty = one item per partition key value
},
}

The composite array is an ordered list of model attribute names. The system generates the key value automatically: $projmgmt#v1#employee#employeeid_emp-alice.

GSI access patterns are defined on the entity via the indexes property. Collections are auto-discovered from entities sharing the same collection name on a GSI. See Indexes & Collections for full details.

Configure automatic metadata at the Entity level:

// Timestamps
timestamps: true
timestamps: { created: "registeredAt", updated: "modifiedAt" }
// Versioning
versioned: true
versioned: { retain: true }
versioned: { field: "revision", retain: true, ttl: Duration.days(90) }
// Soft delete
softDelete: true
softDelete: { ttl: Duration.days(30) }
softDelete: { ttl: Duration.days(30), preserveUnique: true }

See Lifecycle for details on versioning and soft delete.

Opt into auto-generated UUID primary keys with generatedId. The named field is filled with a cryptographically-secure UUID at write time when the caller omits it — a caller-supplied value is always respected. The field MUST exist in the model AND participate in the primary key (pk or sk composite); violating either throws EDD-9008 at make() time.

const Widgets = Entity.make({
model: Widget,
entityType: "Widget",
primaryKey: {
pk: { field: "pk", composite: ["widgetId"] },
sk: { field: "sk", composite: [] },
},
indexes: {
byOwner: {
name: "gsi1",
pk: { field: "gsi1pk", composite: ["owner"] },
sk: { field: "gsi1sk", composite: ["widgetId"] },
},
},
// Auto-generate `widgetId` with a UUIDv4 when the caller omits it.
generatedId: { field: "widgetId" },
})

version defaults to "v4" (random); pass version: "v7" for time-ordered ids. On put/create/upsert the field is optional in the input — omit it to auto-generate, or supply your own to override:

// No `widgetId` supplied — the library fills it from the Crypto service.
const created = yield* db.entities.Widgets.put({
owner: "alice",
label: "Sprocket",
})
// created.widgetId is a freshly-generated UUID

The id is sourced from the bundled Crypto service. DynamoClient.make provides a default globalThis.crypto-backed implementation (no extra dependency), so bound put stays R = never — your program needs no Crypto layer. Pass DynamoClient.make({ crypto }) to override with a platform implementation (e.g. @effect/platform-node).

unique: {
email: ["email"], // single-field
tenantEmail: ["tenantId", "email"], // compound
}

See Data Integrity for details.

A field declared with Schema.withDecodingDefault can be left out of a put. The returned record and every later read carry the default. Whether the default is stored depends on the field:

  • A primary-key or index composite is stored with its default, and its keys are composed from it, so the item is indexed under the value it reads back with. A .remove() of such a field stores the default again and keeps the item indexed under it.
  • Any other field is not stored. The default is applied on read.

An upsert that omits a defaulted index composite reads the item first: it stores the default only when it creates the item, and otherwise keeps the stored value.

A default never creates a unique-constraint sentinel. A defaulted unique field that is also an index composite is listed in a hidden string-set attribute, __edd_d__, until a write supplies its value. See Defaults and Unique Constraints.

Seven types are automatically derived from your Model + Table + Entity declarations. No manual type maintenance required.

// Given Employees with timestamps: true, versioned: true
Entity.Model<typeof Employees>
// { employeeId, tenantId, email, displayName, department, hireDate, createdBy }
Entity.Record<typeof Employees>
// Model + { version, createdAt, updatedAt }
Entity.Input<typeof Employees>
// { employeeId, tenantId, email, displayName, department, hireDate, createdBy }
Entity.Update<typeof Employees>
// { email?, displayName?, department?, hireDate? }
// (employeeId, tenantId excluded — primary key composites)
// (createdBy excluded — immutable: true in DynamoModel.configure)
Entity.Key<typeof Employees>
// { employeeId }
Entity.Item<typeof Employees>
// All physical DynamoDB attributes (pk, sk, gsi1pk, gsi1sk, __edd_e__, ...)
Entity.Marshalled<typeof Employees>
// DynamoDB AttributeValue format ({ pk: { S: "..." }, ... })
Entity.Model ← Pure domain object
↓ + system fields (version, timestamps)
Entity.Record ← What the entity returns
↓ + key attributes (pk, sk, gsi1pk, ...)
Entity.Item ← Full DynamoDB item (unmarshalled)
↓ + DynamoDB encoding
Entity.Marshalled ← DynamoDB AttributeValue format

Entity.Record extends Entity.Model — anywhere that accepts Employee also accepts a Record.

For consuming raw DynamoDB data (e.g., DynamoDB Streams):

// Decode an unmarshalled DynamoDB item to a Record
Entity.itemSchema(Employees)
// Decode a marshalled (AttributeValue) DynamoDB item to a Record
Entity.decodeMarshalledItem(Employees, marshalledItem)

See DynamoDB Streams for usage.