Back to Blog

Storing Feedback at Scale: DynamoDB for Multi-Tenant Projects

Abstract diagram of DynamoDB single-table design with glowing partition key connections
Tapko's single-table DynamoDB model keeps all feedback, projects, and statuses in one place — with no cross-table joins and no cold-start overhead.

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:

table_schema.ts
// 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.