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,
hireDate: Schema.DateTimeUtcFromString,
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.* schemaOverride DynamoDB storage encoding (must match domain type)
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, independent of its wire format. 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.

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.

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 structural key parts

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)

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.

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

See Data Integrity for details.

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.