DEV Community

Cover image for How I Solved DynamoDB + Amplify Search Problem with OpenSearch for $27/Month Instead of Migrating to Aurora
Kohei Aoki
Kohei Aoki

Posted on

How I Solved DynamoDB + Amplify Search Problem with OpenSearch for $27/Month Instead of Migrating to Aurora

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
Enter fullscreen mode Exit fullscreen mode

Data Flow

  1. Write: App writes to DynamoDB as before (Amplify Data models unchanged)
  2. Sync: DynamoDB Streams detects changes and triggers a Lambda function
  3. Index: Lambda upserts/deletes documents in OpenSearch
  4. 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;
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

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.
});
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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

  1. Create an OpenSearch domain — t3.small.search, single instance. ~$27/month
  2. Create indexes with Japanese analyzer — Configure kuromoji tokenizer. Skip this and Japanese search won't work
  3. Enable DynamoDB Streams — Set NEW_AND_OLD_IMAGES on target tables
  4. Deploy sync Lambda — Streams-triggered Lambda that upserts/deletes to OpenSearch. Don't forget the DLQ
  5. 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.

References

Top comments (0)