logo
▼
Projects
Collaborations
Resources
Our Partners
Our Community
Projects
Collaborations
Resources
Our Partners
Our Community
Account
Sign InJoin UsHelp & Support

The Cometbid
Technology Foundation

Empowering innovation through open-source collaboration. TCTF supports developers, organizations, and communities worldwide in building the future of technology with transparent, vendor-neutral governance and world-class open-source projects.


Follow Us

Our Community

  • About Us
  • Upcoming Events
  • Projects
  • Collaborations
  • Membership
  • TCTF Training
  • Corporate Sponsorship

Learn

  • FAQ
  • TCTF Incubator Programs
  • Brand Guidelines
  • Logo Specifications

Legal

  • Privacy Policy
  • Terms of Use
  • Compliance
  • Code of Conduct
  • Contribution Guidelines
  • Legal & Trademark
  • Manage Cookies

More

  • Report a Vulnerability
  • Report Bugs
  • Mailing Lists
  • Contact Us
  • Support
  • Support Tickets
  • TCTF Social Network

Subscribe to our Newsletter

TCTF's DynamoDB Framework, Part 2: Building a Fluent Query Builder in TypeScript
Framework Deep DivesFramework Series #3

TCTF's DynamoDB Framework, Part 2: Building a Fluent Query Builder in TypeScript

How we built a type-safe, chainable query builder that eliminates raw DynamoDB expression strings — making single-table queries readable, composable, and impossible to get wrong.

June 5, 2026· 13 min read
TCTF Editorials
TCTF Newsletter
Home›Newsletter›TCTF's DynamoDB Framework, Part 2: Building a F...

In This Edition

  • The Problem with Raw DynamoDB Expressions
  • The Fluent API: Chainable Query Construction
  • Query Operators and Filter Operators
  • Secure Pagination with Encrypted Tokens
  • Fresh Instances: Preventing State Leaks
  • Validation at Every Step
  • Scan Operations: The Necessary Evil
Fluent/ChainableAPI Style
Full TypeScriptType Safety
8Query Operators
10+Filter Operators
Encrypted TokensPagination
AlwaysFresh Instances

Part 1 covered the single-table design behind all 34 TCTF microservices — partition keys, sort keys, GSIs, entity prefixes, and everything that makes it work. On paper, it looks elegant. But when you actually sit down to write DynamoDB queries, the elegance disappears. You end up managing expression strings, attribute name maps, and attribute value maps all at once — and if you mistype even one placeholder, your query either returns nothing or throws an error that makes no sense. So we built a fluent query builder. You chain methods together, it reads like plain English, and all the complex expression handling happens behind the scenes. This article walks you through exactly how it works, with real code examples.

Tangled cables representing the complexity of raw DynamoDB expression strings that the fluent builder eliminates
Fig. 1 — Tangled cables representing the complexity of raw DynamoDB expression strings that the fluent builder eliminates

01The Problem with Raw DynamoDB Expressions

DynamoDB's query API is powerful, but writing it raw feels like filling out tax forms. Here is what a simple "get users by status" query looks like with the vanilla SDK:

const params = {
  TableName: 'Users',
  IndexName: 'status-index',
  KeyConditionExpression: '#pk = :status',
  FilterExpression: '#createdAt > :date',
  ExpressionAttributeNames: {
    '#pk': 'GSI1PK',
    '#createdAt': 'createdAt',
  },
  ExpressionAttributeValues: {
    ':status': { S: 'ACTIVE' },
    ':date': { S: '2026-01-01' },
  },
};

const result = await dynamodb.query(params).promise();

That is four moving pieces you have to keep perfectly aligned. Miss one, and your query silently breaks. Add a filter? You update the string, update the names map, update the values map. It is tedious and error-prone.

Now here is the same query with our fluent builder:

const result = await service
  .query('Users')
  .index('status-index')
  .where('GSI1PK', '=', 'ACTIVE')
  .filter('createdAt', '>', '2026-01-01')
  .execute();

One chain. Reads like a sentence. The builder constructs all the expression strings, attribute names, and attribute values internally — you never touch them.

Across 34 services and hundreds of Lambda functions, the raw pattern repeats thousands of times. Every repetition is a chance for a typo, a missing attribute name, or a mismatched placeholder. The worst part? A wrong attribute name in ExpressionAttributeNames does not throw — it just returns zero results. Good luck debugging that at 2 AM.

🐛

Raw DynamoDB expressions require four objects kept in sync. One typo returns wrong results silently. The fluent builder eliminates this entire class of bugs.

Building blocks stacking neatly on top of each other, representing the composable chain of query builder methods
Fig. 2 — Building blocks stacking neatly on top of each other, representing the composable chain of query builder methods

02The Fluent API: Chainable Query Construction

The builder works like a sentence — you chain methods together and it reads like describing what you want. Every method returns the builder itself, so you just keep going:

const users = await service
  .query('Users')
  .index('email-index')
  .where('email', '=', 'dev@tctf.com')
  .filter('status', '=', 'active')
  .select(['userId', 'name', 'email'])
  .limit(50)
  .startFrom(nextToken)
  .setScanDirection(false) // descending
  .execute();

The real magic is composability. You can build queries dynamically based on what the request actually needs:

const query = service
  .query('Orders')
  .index('user-orders-index')
  .where('userId', '=', currentUser.id);

// Add filters only if the caller asked for them
if (filters.status) {
  query.filter('status', '=', filters.status);
}
if (filters.startDate && filters.endDate) {
  query.filter('createdAt', 'BETWEEN', filters.startDate, filters.endDate);
}

const results = await query.limit(25).execute();

The builder accumulates conditions as you go and only constructs the final DynamoDB expression when you call .execute().

Two handy shortcuts: .first() adds .limit(1) and returns a single item or null — great for lookups where you expect exactly one result. .count() returns the number of matching items without fetching the actual data.

03Query Operators and Filter Operators

The builder supports all DynamoDB comparison operators. They are defined as TypeScript union types, so your editor catches invalid operators before you even run your code.

There are two categories of operators, and understanding the difference between them is important for both cost and performance.

Query operators are used with .where() for key conditions. These tell DynamoDB which items to read from disk: - = — exact match (find this specific item) - < / <= / > / >= — comparisons (find items before or after a value) - BETWEEN — range query with two values (find items in a date range) - begins_with — prefix matching (find all items starting with a string)

These are efficient because they use the index. DynamoDB only reads the items that match.

Filter operators are used with .filter() for post-query filtering. These tell DynamoDB which items to return after it has already read them: - All the query operators above, plus: - contains — check if a string contains a substring, or if a set contains an element - not_equals — exclude items that match a value - attribute_exists / attribute_not_exists — check whether a field is present at all - IN — check if a value is one of several options

The key thing to understand is the cost difference. .where() controls what DynamoDB reads from the index — this directly affects your bill. .filter() only controls what gets returned to your application after the read has already happened. The read still costs you money regardless of what the filter removes.

For example: if your .where() matches a million items and your .filter() narrows it down to 10, you still paid to read a million items. The lesson is clear — put as much logic as possible into .where(), and use .filter() only for conditions that cannot be expressed through your index design.

The builder enforces this separation at compile time. If you try to use contains inside .where(), TypeScript will give you an error immediately. You cannot accidentally use the wrong operator in the wrong place.

⚡

.where() controls what DynamoDB reads — this is where your cost lives. .filter() controls what comes back to you, but the read already happened. Put as much as possible into .where(). The builder enforces this at compile time.

A vault door with a combination lock, representing the encrypted pagination tokens that hide DynamoDB key structure from clients
Fig. 3 — A vault door with a combination lock, representing the encrypted pagination tokens that hide DynamoDB key structure from clients

04Secure Pagination with Encrypted Tokens

DynamoDB pagination hands you a lastEvaluatedKey — a raw object with your partition key, sort key, and GSI keys baked right in. Here is what that looks like if you naively pass it to the client:

// ❌ DON'T DO THIS — exposes your table internals
return {
  items: results.Items,
  nextToken: results.LastEvaluatedKey,
  // Client now sees: { PK: 'USER#123', SK: 'ORDER#2026-06-01', GSI1PK: 'STATUS#active' }
};

That is a security problem. The client can see your key structure, guess your access patterns, modify the key to skip items, or probe for unauthorized data.

Our builder encrypts it instead:

// ✅ What the builder does automatically
return {
  items: results.Items,
  nextToken: 'eyJhbGciOiJBMjU2R0NNIi...', // opaque, encrypted, tamper-proof
};

The builder integrates with our PaginationEncryption utility. When results include a lastEvaluatedKey, it gets encrypted into an opaque token. The client cannot see the key structure, cannot modify it, and cannot forge a token for a different page.

When the client sends the token back, the builder decrypts it, checks integrity, and resumes the query. Tampered token? Decryption fails, request rejected. The encryption context also binds the token to its purpose — you cannot replay a pagination token as a session token or API key.

In plain terms: your DynamoDB internals stay internal. The client gets a magic string that means "give me the next page" and nothing else.

05Fresh Instances: Preventing State Leaks

Here is a bug that bit us early on. The builder accumulates state as you chain — table, index, conditions, filters, limit, everything. In an early version, we cached builder instances for performance. Two queries in the same Lambda invocation shared one builder. Guess what happened:

// Query A: get active users
await service.query('Users').filter('status', '=', 'active').execute();

// Query B: get all orders (NO filter intended)
await service.query('Orders').execute();
// 🐛 BUG: Query B still has status='active' filter from Query A!

Query B inherited Query A's filter. Subtle, nasty, only shows up when two queries run in the same invocation. It took us a while to track that one down.

The fix is dead simple — every query gets a fresh instance:

// DynamoDBFactory always returns a brand new builder
const builder = DynamoDBFactory.createQueryBuilder(); // fresh, no residual state

// ProductionDynamoDBService does this internally via withQueryBuilder()
// which creates a fresh builder, runs the query, and cleans up

The performance cost? A few object allocations — basically free. The correctness benefit? No state leaks, no cross-query contamination, no 2 AM debugging sessions wondering why your orders query is filtering by user status.

The ProductionDynamoDBService takes it further with a state tracker pattern. Chained methods accumulate state in a separate object. When .execute() fires, a fresh EnhancedQueryBuilder is created, the accumulated state is applied, and the query runs. Even the service-level builder is stateless between operations.

🔒

Every query gets a fresh builder instance. No state leaks between queries. The performance cost is negligible. The correctness benefit is enormous.

06Validation at Every Step

The ProductionDynamoDBService wraps the builder with validation at every step. Here is what gets checked:

  • Table names — valid characters and length
  • Index names — must exist and be well-formed
  • Key condition values — sanitized for injection patterns
  • Filter values — type-checked against expected types
  • Projection attributes — must be non-empty arrays
  • Limits — enforced between 1 and 1000
  • Pagination tokens — integrity-verified before use
  • String values — checked for script injection patterns
  • Attribute names — length-limited to prevent abuse

Why does this matter? Because DynamoDB's native error messages are cryptic. A missing table name gives you something like ValidationException: Value null at 'tableName' failed to satisfy constraint. Our validation gives you INVALID_TABLE_NAME at the exact line where you wrote the query.

A limit of 0? INVALID_LIMIT. A tampered pagination token? INVALID_PAGINATION_TOKEN. You find the bug in seconds, not hours.

The combination of compile-time type safety (TypeScript catches invalid operators) and runtime validation (the service catches invalid values) creates defense in depth. Bugs get caught at the earliest possible point — ideally in your editor, worst case the moment the query runs.

A red warning traffic light, representing that scan operations should be used with caution in production
Fig. 4 — A red warning traffic light, representing that scan operations should be used with caution in production

07Scan Operations: The Necessary Evil

DynamoDB scans read every single item in the table. They are expensive, slow, and you should avoid them in production. But sometimes you genuinely need them — data migrations, analytics, admin tools, cleanup jobs.

Here is how scans look with the builder:

// Scan — reads every item, applies filter after reading
const allInactive = await service
  .scan('Users')
  .filter('status', '=', 'inactive')
  .filter('lastLogin', '<', '2025-01-01')
  .select(['userId', 'email'])
  .limit(100)
  .execute();

// Query — uses an index, reads only matching items
const activeUsers = await service
  .query('Users')
  .index('status-index')
  .where('GSI1PK', '=', 'ACTIVE')
  .execute();

Notice: scans support .filter(), .select(), .limit(), and .startFrom() — but NOT .where(). Calling .where() on a scan throws an error because scans do not use key conditions. If you need .where(), you need a query with an index.

The scan builder uses scanSecure() internally, which applies the same pagination encryption as queries. Even scan results get encrypted tokens.

The separation is intentional. Queries are the happy path — fast, indexed, cheap. Scans are the escape hatch — slow, full-table, expensive. By making them separate methods, the builder makes it painfully obvious when code is scanning. Every scan in production should make you ask: is there an index that could turn this into a query?

When scans are okay: one-off migrations, admin dashboards with low traffic, analytics pipelines running off-peak, cleanup crons that run weekly. When scans are NOT okay: anything in a user-facing request path.

⚠️

Scans read every item in the table. The builder makes scans a separate, obvious path — if you are scanning in production, ask yourself: is there an index that could make this a query?

The fluent query builder sits between your business logic and DynamoDB's expression language. It turns four synchronized objects into one readable chain. It catches typos at compile time. It encrypts pagination tokens so clients cannot poke around your table structure. And it hands every query a fresh instance so state never leaks between operations. Part 3 goes up a level — transactions for atomic multi-item writes, batch operations for throughput, and the retry/circuit breaker integration that keeps everything production-ready when DynamoDB has a bad day. We are planning to open-source the full TCTF DynamoDB framework once it matures — the query builder, transaction builder, repository layer, provider architecture, and encrypted pagination system. It is what makes working with DynamoDB at scale actually manageable. Stay tuned.

Editor's Note: This is Framework Series #3, Part 2 of the DynamoDB series. Part 1 covered single-table design patterns. Part 3 covers transactions and advanced patterns. Next in the series: Rate Limiting at Serverless Scale.

Never miss an edition

Subscribe to get TCTF newsletters delivered to your inbox.

Subscribe
PreviousThe Struggles of Timelines and Schedules: When Building Gets Real
NextRate Limiting at Serverless Scale: Tiered Throttling with DynamoDB

In This Edition

  • The Problem with Raw DynamoDB Expressions
  • The Fluent API: Chainable Query Construction
  • Query Operators and Filter Operators
  • Secure Pagination with Encrypted Tokens
  • Fresh Instances: Preventing State Leaks
  • Validation at Every Step
  • Scan Operations: The Necessary Evil

Browse by Month

June
  • Caching Strategies for Serverless, Part 2: ElastiCache Redis for Low-Latency Session and API Caching
  • Caching Strategies for Serverless: From In-Memory to DynamoDB-Backed TTL Caches
  • Rate Limiting at Serverless Scale: Tiered Throttling with DynamoDB
  • TCTF's DynamoDB Framework, Part 2: Building a Fluent Query Builder in TypeScript
May
  • The Struggles of Timelines and Schedules: When Building Gets Real
  • How to Stay Motivated in the Face of Uncertainties: Faith Beyond Doubt
  • Cognito Middleware: Building an Authentication Pipeline for Serverless APIs
  • Building TCTF's DynamoDB Query Framework, Part 1: Single-Table Design Patterns
April
  • Built to Last: Why Sustained Collaboration Is the Future of Tech Teams
  • Q2 2026 Roadmap: What's Next for the TCTF Portal
March
  • How We Built a Real-Time Messaging System with AWS Lambda and WebSockets
  • From Forum to Social Network: The Origin Story of Cometbid Social
  • Agentic AI: What It Means for Software Development and Why We're Paying Attention
February
  • Platform Update: Social Network Architecture, Achievement Engine, and What's Next
  • How We Built 34 Serverless Microservices: Architecture Patterns Behind the TCTF Platform
January
  • How We Secure the TCTF Platform: Principles Every Developer Should Know
  • New Year, New Projects: TCTF 2026 Roadmap

More From TCTF Newsletter

Built to Last: Why Sustained Collaboration Is the Future of Tech TeamsVol. 1, Issue 4

Built to Last: Why Sustained Collaboration Is the Future of Tech Teams

Most platforms optimize for transactions — post a job, hire, move on. TCTF is built around sustained collaboration: long-term teams, milestone-driven projects, language support that breaks barriers, and a community where everyone — not just developers — has a seat at the table.

April 15, 2026
Q2 2026 Roadmap: What's Next for the TCTF PortalQ2 2026

Q2 2026 Roadmap: What's Next for the TCTF Portal

Our quarterly roadmap for Q2 — what shipped in April, the origin of Cometbid Social, and the plan for May and June as we build toward user accounts, authentication, and the social network launch.

April 1, 2026
How We Built a Real-Time Messaging System with AWS Lambda and WebSocketsTech Series #3

How We Built a Real-Time Messaging System with AWS Lambda and WebSockets

Inside the architecture of TCTF's messaging platform — three services handling real-time chat, campaign delivery, and transactional notifications, all built on Lambda, API Gateway WebSockets, SQS, and multi-provider email with automatic failover.

March 15, 2026

Browse by Month

2026

June
  • Caching Strategies for Serverless, Part 2: ElastiCache Redis for Low-Latency Session and API Caching
  • Caching Strategies for Serverless: From In-Memory to DynamoDB-Backed TTL Caches
  • Rate Limiting at Serverless Scale: Tiered Throttling with DynamoDB
  • TCTF's DynamoDB Framework, Part 2: Building a Fluent Query Builder in TypeScript
May
  • The Struggles of Timelines and Schedules: When Building Gets Real
  • How to Stay Motivated in the Face of Uncertainties: Faith Beyond Doubt
  • Cognito Middleware: Building an Authentication Pipeline for Serverless APIs
  • Building TCTF's DynamoDB Query Framework, Part 1: Single-Table Design Patterns
April
  • Built to Last: Why Sustained Collaboration Is the Future of Tech Teams
  • Q2 2026 Roadmap: What's Next for the TCTF Portal
March
  • How We Built a Real-Time Messaging System with AWS Lambda and WebSockets
  • From Forum to Social Network: The Origin Story of Cometbid Social
  • Agentic AI: What It Means for Software Development and Why We're Paying Attention
February
  • Platform Update: Social Network Architecture, Achievement Engine, and What's Next
  • How We Built 34 Serverless Microservices: Architecture Patterns Behind the TCTF Platform
January
  • How We Secure the TCTF Platform: Principles Every Developer Should Know
  • New Year, New Projects: TCTF 2026 Roadmap