Every Tapko project is a container for user feedback. Behind the scenes, each project holds dozens — sometimes thousands — of individual submissions, each with a status, a timestamp, and a payload that can vary in shape. When we first built the backend, we reached for the most obvious data model: one DynamoDB table per entity. Projects in one table. Feedbacks in another. It worked fine at low volume. Then it didn't.
The Multi-Tenant Problem
Tapko is a multi-tenant platform. Every user who signs up creates their own projects, and each project collects feedback independently. The naive approach — a Projects table and a Feedbacks table with a projectId foreign key — forces you into a relational mindset that DynamoDB punishes at scale.
The root problem is access patterns. We needed to: list all feedbacks for a project, fetch a single feedback by ID, filter feedbacks by status (open, in-progress, resolved), and update a feedback's status without reading the whole record. Each of these patterns maps to a different DynamoDB query shape, and with separate tables, every pattern requires a scan or a GSI that balloons your read units.
Switching to Single-Table Design
The solution was to collapse everything into one table and let the partition key and sort key do the heavy lifting. Here's the core key schema we landed on:
// Partition key (PK) + Sort key (SK) patterns:
// Project record: PK = USER#<userId> SK = PROJECT#<projectId>
// Feedback record: PK = PROJECT#<projectId> SK = FEEDBACK#<feedbackId>
// Status index: GSI PK = PROJECT#<projectId> GSI SK = STATUS#<status>#<createdAt>
const FeedbackItem = {
PK: `PROJECT#${projectId}`,
SK: `FEEDBACK#${feedbackId}`,
GSI1PK: `PROJECT#${projectId}`,
GSI1SK: `STATUS#${status}#${createdAt}`,
userId,
projectId,
feedbackId,
message,
status, // 'open' | 'in-progress' | 'resolved'
createdAt,
updatedAt,
};With this layout, fetching all feedback for a project is a single Query on the base table using PK = PROJECT#<projectId>. Filtering by status hits the GSI. Updating a status is a targeted UpdateItem — no reads required. Our read unit consumption dropped by 60% compared to the multi-table design.
“Model your DynamoDB table around access patterns, not around entities. Your entities should bend to serve the queries, not the other way around.”
Handling the isCollectingFeedback Flag
Projects have an isCollectingFeedback flag that controls whether the embedded widget can submit new entries. This flag lives on the project record itself, so toggling it is a single UpdateItem on the project's PK/SK. The Lambda handler for feedback submission reads this flag before writing — if it's false, it returns a 400 immediately. No feedback table scan needed.
Single-Table Design
All projects and feedbacks in one table — zero cross-table queries, predictable cost at any scale.
GSI-Driven Filtering
Status-based feedback queries resolve in under 5ms with a single GSI — no full-table scans.
What We Learned
The biggest mindset shift was accepting that DynamoDB is not a relational database with a different syntax. It's a key-value store that rewards you for thinking about reads before you think about writes. Every schema decision we make starts with “how will this be queried?” — not “what does this entity look like?”
If you're building a multi-tenant SaaS and considering DynamoDB, start with your access patterns on a whiteboard. Write them all down. Then design one table around all of them. You'll save yourself a painful migration later.