
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.
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.

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.

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.
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.

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.
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 upThe 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.
The ProductionDynamoDBService wraps the builder with validation at every step. Here is what gets checked:
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.

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.
Never miss an edition
Subscribe to get TCTF newsletters delivered to your inbox.