TL;DR: One OpenSearch instance. $27/month. DynamoDB search and multi-tenancy — solved.
We Were About to Migrate to Aurora
I had the Aurora estimate ready. I was planning to rewrite our Amplify Data models into SQL schemas.
We had two problems. The first was search — once filter conditions exceeded 5 fields, DynamoDB's Query/Scan couldn't keep up. Full-text search was simply impossible. The second was multi-tenancy — we needed to safely isolate data across multiple products and multiple environments (staging / prod / sandbox). With Amplify Gen2's sandbox spinning up per-developer environments, naively adding a search engine would multiply instances and blow up costs.
In the end, we didn't migrate to Aurora. We added OpenSearch as a "search layer" alongside DynamoDB, and used index naming conventions to consolidate multiple products and environments into a single instance. The additional cost: $27/month — a 94% reduction from the $432/month we would have spent on per-environment instances. Both search and multi-tenancy, solved.
This article shares how we implemented this in a real-world real estate tech product running on Amplify Gen2.
The Pain Points of DynamoDB-Only
We're running a real estate tech product built on Amplify Gen2. Property and lot data lives in DynamoDB, with search and listing features for users. Multiple products share the same AWS account, each with staging, prod, and per-developer sandbox environments.
Early on, DynamoDB Query with GSIs (Global Secondary Indexes) was fine. But as the product grew, we hit walls on both search and multi-tenancy.
Search Problems
1. Complex Filter Conditions Don't Scale
Real estate search means combining "area + price range + floor size + walking distance to station + building age" — easily 5+ conditions. DynamoDB Query only filters on partition key and sort key.
You can add FilterExpression for extra conditions, but filters apply after the Query runs. So you might fetch 1,000 records just to filter down to 10. That's wasteful.
2. No Full-Text Search
Searching for "Shibuya office pet-friendly" is impossible in DynamoDB. The contains operator exists but requires a full Scan — not practical at scale.
3. Inflexible Sorting and Pagination
DynamoDB sorting depends on the sort key. Switching between "sort by price," "sort by size," and "sort by newest" each requires a separate GSI. The GSI limit is 20, but once you factor in filter combinations, it's nowhere near enough.
Pagination is cursor-based only (ExclusiveStartKey) — you can't jump directly to page 3.
Multi-Tenancy Problems
4. Data Isolation Across Products and Environments
Our products share Amplify backend patterns. DynamoDB naturally isolates data by table, so cross-product contamination isn't an issue there.
The problem emerges when you add a search engine. If you create a separate search instance per product and environment, instances multiply fast: 2 products x 3 environments (staging / prod / sandbox) = 6 instances. With 5 developers, sandbox alone means 10 instances. At $27 each: $432/month — just for adding search.
5. Amplify Sandbox Proliferation
Amplify Gen2's npx ampx sandbox creates isolated environments per developer. This works perfectly for DynamoDB, but it's devastating for always-on services like OpenSearch. Each sandbox creates an instance, takes minutes to boot, and if someone forgets to shut it down, costs pile up.
Keep DynamoDB, Add a Search Layer
"If search is the problem, just migrate to an RDB" — we considered it seriously. Aurora Serverless v2 is the strongest managed RDB option on AWS. But even with Aurora, multi-tenancy still needs separate design work, and we didn't see enough upside to justify abandoning DynamoDB + Amplify's developer experience.
Cost Comparison
| DynamoDB + OpenSearch | Aurora Serverless v2 | |
|---|---|---|
| Monthly cost (min config) | DynamoDB: pay-per-use (~$5) + OpenSearch t3.small: ~$27 | Min 0.5 ACU: ~$60–70 (Tokyo region) |
| Total | ~$30–50/month | ~$60–100/month |
| Storage | DynamoDB: $0.25/GB + OpenSearch: $0.122/GB (gp3) | $0.12/GB (Aurora storage) |
| Scaling | DynamoDB: auto-scale, OpenSearch: fixed instance | ACU-based auto-scaling |
Aurora Serverless v2 charges even at minimum 0.5 ACU. In Tokyo region, that's ~$0.20/hour × 0.5 ACU × 730 hours = ~$73/month — even when idle.
DynamoDB is pay-per-use (nearly zero when idle), and OpenSearch t3.small.search runs at ~$27/month. Combined, it's less than half of Aurora.
Amplify Compatibility
Amplify Gen2 uses DynamoDB as its default data store. Define your schema with defineData() and GraphQL APIs + DynamoDB tables are auto-generated. This developer experience is excellent.
Migrating to Aurora means giving that up: self-managed SQL schemas, migrations, ORM configuration. Keeping DynamoDB as the primary data store and adding OpenSearch as a search layer minimizes architectural changes.
Search Performance
OpenSearch is purpose-built for search: full-text search, faceted search, geo search, scoring — all things Aurora's LIKE clause or full-text indexes can't match. For complex real estate search with many filter combinations, OpenSearch is the clear winner.
Architecture: DynamoDB Streams + Lambda + OpenSearch
The architecture is straightforward:
[DynamoDB] → DynamoDB Streams → [Lambda] → [OpenSearch]
↑
Search API reads from here
Data Flow
- Write: App writes to DynamoDB as before (Amplify Data models unchanged)
- Sync: DynamoDB Streams detects changes and triggers a Lambda function
- Index: Lambda upserts/deletes documents in OpenSearch
- Search: Search API queries OpenSearch and returns results
The key benefit: zero changes to the existing write path. DynamoDB CRUD goes through Amplify's GraphQL API as always — only search reads from OpenSearch.
Sync Lambda (Conceptual)
// DynamoDB Streams → Lambda → OpenSearch
export const handler = async (event: DynamoDBStreamEvent) => {
for (const record of event.Records) {
const tableName = extractTableName(record.eventSourceARN);
const indexName = tableToIndex(tableName); // Table name → index name mapping
switch (record.eventName) {
case 'INSERT':
case 'MODIFY':
await opensearchClient.index({
index: indexName,
id: record.dynamodb.Keys.id.S,
body: unmarshall(record.dynamodb.NewImage),
});
break;
case 'REMOVE':
await opensearchClient.delete({
index: indexName,
id: record.dynamodb.Keys.id.S,
});
break;
}
}
};
Search Query: Before / After
Before: DynamoDB (multi-condition filtering)
// DynamoDB: Partition key + FilterExpression workaround
const result = await dynamoClient.query({
TableName: 'Properties',
KeyConditionExpression: '#area = :area',
FilterExpression: '#price BETWEEN :minPrice AND :maxPrice AND #size >= :minSize',
ExpressionAttributeValues: { ':area': 'Shibuya', ':minPrice': 5000000, ':maxPrice': 10000000, ':minSize': 50 },
// → Fetches 1,000 records, filters down to 10. Inefficient.
});
After: OpenSearch (same conditions + full-text search + sort)
// OpenSearch: Combine any conditions + full-text + sort in one query
const result = await opensearchClient.search({
index: 'projectA-prod-properties',
body: {
query: {
bool: {
must: [
{ match: { description: 'pet-friendly office' } }, // Full-text search
{ term: { area: 'Shibuya' } },
{ range: { price: { gte: 5000000, lte: 10000000 } } },
{ range: { size: { gte: 50 } } },
],
},
},
sort: [{ price: 'asc' }], // Sort by any field
from: 0, size: 20, // Pagination
},
});
// → Returns exactly 20 matching records. 100–200ms response time.
Full-text search + multi-condition filtering + sorting + pagination — all in one query. This was impossible with DynamoDB alone.
The Gotcha: Japanese Full-Text Search Doesn't Work by Default
The first thing that bit us after adding OpenSearch: Japanese full-text search returned zero results.
Searching for "Shibuya office" returned nothing. The root cause: OpenSearch's default analyzer (standard analyzer) doesn't support Japanese morphological analysis. "渋谷オフィス" (Shibuya Office) was treated as a single token, so partial matches failed.
The fix: configure ICU analyzer + kuromoji tokenizer when creating the index.
{
"settings": {
"analysis": {
"analyzer": {
"japanese_analyzer": {
"type": "custom",
"tokenizer": "kuromoji_tokenizer",
"filter": ["kuromoji_baseform", "lowercase"]
}
}
}
},
"mappings": {
"properties": {
"description": { "type": "text", "analyzer": "japanese_analyzer" }
}
}
}
The moment we added this config, Japanese keyword search started working correctly. OpenSearch's Japanese search doesn't work out of the box — not knowing this was our biggest time waste.
Note for English-language deployments: If your data is English-only, the default standard analyzer works fine. The kuromoji tokenizer is specifically needed for CJK (Chinese/Japanese/Korean) text segmentation.
Operational Considerations
DynamoDB Streams + Lambda sync comes with a few operational caveats:
- Sync latency: Streams → Lambda → OpenSearch propagation typically takes a few hundred ms to a few seconds. For screens requiring real-time data, add a fallback that reads directly from DynamoDB immediately after writes
- Lambda error retries: If the Streams-triggered Lambda fails repeatedly, records can expire and be lost. Set up a DLQ (Dead Letter Queue) to catch failures. Note: DynamoDB Streams has a 24-hour retention window — if your Lambda is down for longer than that, events are permanently lost
- Index rebuilds: When you change OpenSearch mappings, existing data needs re-indexing. Keep a script ready that does a full DynamoDB Scan → OpenSearch Bulk Insert
Multi-Tenancy Design: 1 Instance × Index Isolation
Search was solved. Next: multi-tenancy — how to safely isolate multiple products × multiple environments while keeping costs down.
Solution: Share One Instance, Isolate by Index
Instead of creating an OpenSearch instance per environment, we share a single instance across all environments and isolate by index name.
OpenSearch Domain (1 instance)
├── projectA-stg-properties ← Product A staging properties
├── projectA-stg-sections ← Product A staging lots
├── projectA-prod-properties ← Product A prod properties
├── projectA-prod-sections ← Product A prod lots
├── projectB-stg-properties ← Product B staging properties
├── projectB-prod-properties ← Product B prod properties
└── sandbox-{user}-properties ← Sandbox (only when needed)
Key decisions:
- No OpenSearch resources for sandbox — In CDK/Amplify backend definitions, skip OpenSearch resource creation for sandbox environments
- Sandbox Lambda points to shared dev OpenSearch — Inject the OpenSearch endpoint and index name via environment variables. Sandbox either uses the stg instance or falls back to direct DynamoDB reads
-
Index names include project and environment —
{project}-{env}-{entity}naming convention prevents collisions
This approach keeps OpenSearch instances at the absolute minimum (1 shared) while safely isolating data across multiple products and environments. What would have been $432/month with 16 instances is now $27/month with one.
What We Gained
Search
| Before (DynamoDB only) | After (DynamoDB + OpenSearch) | |
|---|---|---|
| Filtering | GSI + FilterExpression (2–3 conditions max) | Combine any number of conditions |
| Full-text search | Not possible | Japanese morphological analysis supported |
| Sorting | Sort key dependent (needs GSI) | Sort by any field |
| Pagination | Cursor-based only | from/size for direct page jumps |
| Response time | Scan could take seconds | Complex queries in 100–200ms |
Multi-Tenancy
| Before (separate instances) | After (1 instance × index isolation) | |
|---|---|---|
| Instances | 1 per product × environment | 1 total |
| Monthly cost | Up to $432/month (16 instances) | $27/month (1 instance) |
| Sandbox startup | Wait for OpenSearch boot (minutes) | Skip (uses shared stg instance) |
| Data isolation | Instance-level | Index naming convention (logical) |
Getting Started: 5 Steps
- Create an OpenSearch domain — t3.small.search, single instance. ~$27/month
- Create indexes with Japanese analyzer — Configure kuromoji tokenizer. Skip this and Japanese search won't work
-
Enable DynamoDB Streams — Set
NEW_AND_OLD_IMAGESon target tables - Deploy sync Lambda — Streams-triggered Lambda that upserts/deletes to OpenSearch. Don't forget the DLQ
- Add a search API — Query OpenSearch from a new endpoint. Keep existing DynamoDB CRUD APIs untouched
When your GSI count exceeds 5, or your FilterExpression hit rate drops below 10% — that's when to consider adding OpenSearch.
Wrapping Up
We were ready to migrate to Aurora. We had the estimate. But in the end, we didn't need to abandon DynamoDB.
One OpenSearch instance solved both search and multi-tenancy. Writes go to DynamoDB, search reads from OpenSearch. DynamoDB Streams + Lambda keeps them in sync. Index naming ({project}-{env}-{entity}) consolidates multiple products and environments into a single instance. Amplify Gen2's DynamoDB-centric developer experience stays intact, and search capabilities scale independently.
The beauty of this "patch the weakness" approach: you don't have to rewrite your architecture all at once. Start with DynamoDB. When search requirements grow, add OpenSearch. When products multiply, add indexes. At $27/month, there was no reason to migrate to Aurora.
When NOT to Use This Approach
This pattern isn't universal. Consider a full RDB migration if you need complex relational queries across entities, ACID transactions spanning multiple tables, or your search requirements involve heavy aggregations that OpenSearch alone can't serve back to the write path. If your product is SQL-first from the start, don't force DynamoDB into the picture just to save a few dollars.
But if you're already running DynamoDB and your pain is search — try patching the weakness with OpenSearch before rewriting everything. The best architecture isn't always the newest one. Sometimes it's the cheapest complement to what you already have.
Top comments (0)