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.
Defining Your Domain Model
Section titled “Defining Your Domain Model”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.
Adding DynamoDB Concerns
Section titled “Adding DynamoDB Concerns”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.
Available Options
Section titled “Available Options”Each field accepts an AttributeConfig object with these options:
| Option | Type | Description |
|---|---|---|
field | string | Rename the domain field to a different DynamoDB attribute name |
storedAs | DynamoModel.* schema | Override DynamoDB storage encoding (must match domain type) |
immutable | boolean | Exclude from Entity.Update — read-only after creation |
identifier | boolean | Mark as the identity field for ref/aggregate resolution |
ref | boolean | Mark as a denormalized reference to another entity |
Field Renaming
Section titled “Field Renaming”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"Storage Encoding
Section titled “Storage Encoding”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.
Immutable Fields
Section titled “Immutable Fields”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 createdByPrimary key composite attributes are inherently immutable — you don’t need to mark them.
Identifier and Ref
Section titled “Identifier and Ref”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})Combining Options
Section titled “Combining Options”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 },})Namespacing Your Application
Section titled “Namespacing Your Application”DynamoSchema defines the application-level namespace that prefixes every generated key.
const AppSchema = DynamoSchema.make({ name: "projmgmt", version: 1, casing: "lowercase",})Properties
Section titled “Properties”| Property | Type | Default | Description |
|---|---|---|---|
name | string | required | Application name, used as key prefix |
version | number | required | Schema version for migration support |
casing | "lowercase" | "uppercase" | "preserve" | "lowercase" | Casing for structural key parts |
Why Schema Versioning?
Section titled “Why Schema Versioning?”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.
Why Application Namespace?
Section titled “Why Application Namespace?”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 appCasing
Section titled “Casing”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)Declaring the Physical Table
Section titled “Declaring the Physical Table”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).
Properties
Section titled “Properties”| Property | Type | Description |
|---|---|---|
schema | DynamoSchema | Application schema for key prefixing |
entities | Record<string, Entity> | Named entities registered on this table |
aggregates | Record<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.
Binding Models to Storage
Section titled “Binding Models to Storage”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) },})Primary Key Definition
Section titled “Primary Key Definition”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.
System Fields
Section titled “System Fields”Configure automatic metadata at the Entity level:
// Timestampstimestamps: truetimestamps: { created: "registeredAt", updated: "modifiedAt" }
// Versioningversioned: trueversioned: { retain: true }versioned: { field: "revision", retain: true, ttl: Duration.days(90) }
// Soft deletesoftDelete: truesoftDelete: { ttl: Duration.days(30) }softDelete: { ttl: Duration.days(30), preserveUnique: true }See Lifecycle for details on versioning and soft delete.
Unique Constraints
Section titled “Unique Constraints”unique: { email: ["email"], // single-field tenantEmail: ["tenantId", "email"], // compound}See Data Integrity for details.
Derived Types
Section titled “Derived Types”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: "..." }, ... })Type Hierarchy
Section titled “Type Hierarchy”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 encodingEntity.Marshalled ← DynamoDB AttributeValue formatEntity.Record extends Entity.Model — anywhere that accepts Employee also accepts a Record.
Schema Accessors
Section titled “Schema Accessors”For consuming raw DynamoDB data (e.g., DynamoDB Streams):
// Decode an unmarshalled DynamoDB item to a RecordEntity.itemSchema(Employees)
// Decode a marshalled (AttributeValue) DynamoDB item to a RecordEntity.decodeMarshalledItem(Employees, marshalledItem)See DynamoDB Streams for usage.
What’s Next?
Section titled “What’s Next?”- Indexes & Collections — Define access patterns with primary and secondary indexes
- Queries — Use the pipeable Query API
- Data Integrity — Unique constraints and optimistic concurrency
- Lifecycle — Soft delete, versioning, TTL