Skip to content

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:

IsolatedClustered
Single-entity queryEfficient - contiguous range readWasteful - reads the partition, filters
Collection queryReads the partition, sorts client-sideEfficient - related items adjacent
Best forPartitions with one dominant entity typePartitions 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.

Join the waitlist →


Related: SaaS Multi-Tenant pattern - the full schema this post’s examples are drawn from. ElectroDB GSI Overloading - collapsing three GSIs into one.

Tejovanth N

Tejovanth builds on DynamoDB in production: rasika.life, rekha.app, rrmstays. All single-table with ElectroDB.

LinkedIn codeculturecob.com

Related

Schema review

Want a second pair of eyes before you ship?

Async DynamoDB schema review. PK/SK design, GSI strategy, ElectroDB entity code. Fixed price, 5 business days.