ElectroDB Single-Table Design: Services, Collections, and Clustered Indexes
Most ElectroDB tutorials show you one Entity. That’s enough to write to DynamoDB, but it isn’t single-table design, and it skips the part of the library that actually solves the hard problem.
The hard problem is this: you’ve put four entity types in one table so that a single query can return a tenant, its users, and its projects together. Now how do you write that query in a typed way, without hand-rolling key strings and manually sorting the results back into entity types?
ElectroDB’s answer is Services and Collections. This post covers both, plus the index type decision that determines whether your collection queries are efficient or wasteful.
Entities are not enough
An Entity in ElectroDB models one item type and its indexes. It generates typed queries, handles key composition, and validates attributes. It does all of that in isolation.
const users = await UserEntity.query.primary({ tenantId: "t_01" }).go()
const projects = await ProjectEntity.query.primary({ tenantId: "t_01" }).go()
Two entities, two queries, two round trips. That works, and if your access patterns never need related entity types together, stop here. Multiple entities sharing a table without needing cross-entity queries is a perfectly reasonable design.
But if you’re doing single-table design, you’re doing it precisely because you want one query to return several entity types at once. Two round trips defeats the purpose. That’s what a Service is for.
Services: the container that makes cross-entity queries possible
A Service groups entities that share a table and lets you query across them:
import { Service } from "electrodb"
export const SaaSService = new Service(
{
tenant: TenantEntity,
user: UserEntity,
project: ProjectEntity,
subscription: SubscriptionEntity,
},
{ client, table }
)
Two requirements, and both fail loudly if you get them wrong:
Every entity must declare the same model.service value.
model: { entity: "user", version: "1", service: "saas" }
model: { entity: "project", version: "1", service: "saas" }
The service string is how ElectroDB knows these entities belong to the same schema. Mismatched values are rejected at Service construction.
Index names and field names must be consistent across entities. If UserEntity calls its GSI gsi1 with fields gsi1pk and gsi1sk, then ProjectEntity must use exactly the same names for the same physical index. ElectroDB validates this when you build the Service, which is a genuinely useful safety net - it catches the class of bug where one entity writes gsi1pk and another writes GSI1PK and neither query ever returns the other’s items.
Collections: one query, multiple entity types
A Collection marks an index as participating in a cross-entity query. You declare it on the index definition, in every entity that should appear in that query:
export const TenantEntity = new Entity({
model: { entity: "tenant", version: "1", service: "saas" },
attributes: {
tenantId: { type: "string", required: true },
name: { type: "string", required: true },
plan: { type: "string", required: true, default: "free" },
},
indexes: {
primary: {
collection: "tenantRecords",
pk: { field: "pk", composite: ["tenantId"], template: "TENANT#${tenantId}" },
sk: { field: "sk", composite: [] },
},
},
}, { client, table }); Now one query returns all three:
const result = await SaaSService.collections
.tenantRecords({ tenantId: "t_01" })
.go()
result.data.tenant // TenantEntity[] - fully typed
result.data.user // UserEntity[]
result.data.project // ProjectEntity[]
One DynamoDB Query. Three typed arrays. No manual key parsing, no switch on an entity type discriminator.
The constraint that makes this work: every entity in a collection must have an identical partition key composite on that index. All three use ["tenantId"] producing TENANT#<tenantId>. That’s not an ElectroDB rule so much as physics - a single Query reads one partition, so everything you want back has to be in it.
Entity identifiers: the hidden attributes
ElectroDB writes two extra attributes to every item:
__edb_e__: "user" // entity name
__edb_v__: "1" // model version
These are how collection queries sort results into typed buckets, and how single-entity queries filter out items belonging to other entities that share the partition.
Three things worth knowing:
They cost storage on every item. Small, but real at scale.
Queries filter on them. UserEntity.query.primary({ tenantId }) reads the whole partition and filters to user items. If the partition holds ten thousand projects and five users, you pay read capacity for all ten thousand. This is the single most expensive mistake in ElectroDB single-table design, and the index type setting is how you avoid it.
You can rename them if you’re adopting ElectroDB over an existing table:
const UserEntity = new Entity(schema, { client, table })
UserEntity.setIdentifier("entity", "_type")
UserEntity.setIdentifier("version", "_v")
Isolated vs clustered indexes
This is the setting most guides don’t mention, and it changes the physical layout of your data.
indexes: {
primary: {
type: "clustered", // or "isolated" (the default)
collection: "tenantRecords",
pk: { field: "pk", composite: ["tenantId"] },
sk: { field: "sk", composite: ["projectId"] },
},
}
Isolated (default) prefixes the sort key with the entity name. Each entity type occupies its own contiguous block within the partition:
$saas#tenant_1#tenantid_t01
$saas#user_1#userid_u01
$saas#user_1#userid_u02
$saas#project_1#projectid_01HVMK
$saas#project_1#projectid_01HVNR
Clustered does not. Entity types interleave, ordered purely by their sort key composites.
The trade-off is direct:
| Isolated | Clustered | |
|---|---|---|
| Single-entity query | Efficient - contiguous range read | Wasteful - reads the partition, filters |
| Collection query | Reads the partition, sorts client-side | Efficient - related items adjacent |
| Best for | Partitions with one dominant entity type | Partitions genuinely read as a unit |
Choose isolated when your dominant access patterns fetch one entity type. “List a tenant’s users” shouldn’t pay to read its ten thousand projects.
Choose clustered when the collection query is the point and entity counts per partition are small and comparable. An order with its five line items is the archetype - you almost always want them together.
The default is isolated, and for most schemas that’s right. Reach for clustered deliberately, when you can name the collection query that justifies it.
Sub-collections: partial collection queries
You can nest collections to query subsets. Declare an array, most general first:
// User
indexes: {
primary: {
collection: ["tenantRecords", "tenantMembers"],
pk: { field: "pk", composite: ["tenantId"] },
sk: { field: "sk", composite: ["userId"] },
},
}
// Invitation
indexes: {
primary: {
collection: ["tenantRecords", "tenantMembers"],
pk: { field: "pk", composite: ["tenantId"] },
sk: { field: "sk", composite: ["inviteId"] },
},
}
Now you have two query granularities:
SaaSService.collections.tenantRecords({ tenantId }) // everything
SaaSService.collections.tenantMembers({ tenantId }) // users + invitations only
Sub-collections only work on isolated indexes. That is easy to get backwards, since clustered is the type built for collection queries. Isolated indexes prefix the entity onto the sort key and keep each entity’s items from co-mingling, so a named subset of entities stays an addressable range. Clustered indexes intermix entities to make the whole-partition read cheap, and that intermixing removes the range a sub-collection needs.
Collections on a GSI
Collections work on secondary indexes too, and that is where they earn their keep. Define a collection on an overloaded GSI and you can fetch related items across partitions in one query:
// Order
indexes: {
byCustomer: {
index: "gsi1",
collection: "customerActivity",
pk: { field: "gsi1pk", composite: ["customerId"], template: "CUSTOMER#\${customerId}" },
sk: { field: "gsi1sk", composite: ["orderId"] },
},
}
// SupportTicket
indexes: {
byCustomer: {
index: "gsi1",
collection: "customerActivity",
pk: { field: "gsi1pk", composite: ["customerId"], template: "CUSTOMER#\${customerId}" },
sk: { field: "gsi1sk", composite: ["ticketId"] },
},
}
One query returns a customer’s orders and support tickets together. Note the index is being shared by entities with unrelated key patterns - that’s GSI overloading, and it deserves its own treatment. See ElectroDB GSI Overloading.
What ElectroDB doesn’t do
Worth being clear about the boundaries.
It doesn’t create tables. ElectroDB is a data access layer. Table and GSI definitions live in your IaC - CDK, SST, Terraform. Nothing stops the two from drifting apart, and nothing warns you when they do. Adding a GSI to an entity without adding it to your infrastructure produces a runtime error at query time, not a build error.
It doesn’t validate access pattern coverage. ElectroDB will happily let you define a schema where an access pattern requires a Scan. It generates typed methods for the indexes you declared. Whether those indexes serve your requirements is entirely on you.
It doesn’t migrate data. Changing a key template changes how new items are written. Existing items keep their old keys and become unreachable through the new query. model.version exists to let old and new coexist, but the backfill is yours to write.
When ElectroDB is the wrong choice
The entity identifier attributes and generated key formats mean ElectroDB expects to own the table. Adopting it over an existing DynamoDB table with hand-rolled keys means either matching your existing format exactly through templates, or migrating.
If you have one entity type and three access patterns, the raw SDK v3 DocumentClient is less abstraction for the same result.
And if you’re not sure single-table design is right for your project at all, decide that first: When DynamoDB Single-Table Design Is the Wrong Choice.
Design This Visually → Coming Soon
Every decision in this post - collection membership, partition key alignment, isolated versus clustered - is a decision about physical layout that you’re currently making in your head and discovering the consequences of in CloudWatch.
That’s what I’m building at singletable.dev: a visual designer that shows you the item layout your ElectroDB schema produces, and exports the entity definitions.
Related: SaaS Multi-Tenant pattern - the full schema this post’s examples are drawn from. ElectroDB GSI Overloading - collapsing three GSIs into one.