TrackIt
TrackIt
Contact us
Blogs

Why We Switched from DynamoDB to Postgres on Aurora Serverless v2

Author

Antoine Berger

Date Published


TL;DR

This migration story comes from a cloud-native backend designed to automate and orchestrate workloads across AWS environments. The system collected configuration data, applied business rules, and generated insights used by other internal services. At the outset, the priority was clear: build fast, keep costs predictable, and avoid managing infrastructure. DynamoDB aligned perfectly with that phase; it was serverless, elastic, and capable of handling unpredictable traffic with minimal operational effort.

Over time, however, the workload became more interconnected. What began as a set of independent resources evolved into a graph of related entities: users, jobs, resources, and results, each requiring cross-references, queries, and transactional updates. Application logic started to handle responsibilities better suited to a relational database, such as maintaining consistency across related records and filtering complex datasets.

That shift forced a question every maturing serverless project eventually faces: how far can a NoSQL model stretch before it starts bending the domain? In this case, the turning point came when development slowed under the weight of denormalization and post-query filtering, prompting a move to PostgreSQL on Aurora Serverless v2 to preserve the serverless experience while gaining the relational structure needed for long-term simplicity.

The following sections talk about a real-world experience the TrackIt team had in developing a tool that initially leveraged DynamoDB.

The Setup: Picked DynamoDB to Move Fast — Met a Relational Reality

The initial architecture leaned toward AWS-native, fully managed components. With a variable and unpredictable workload, DynamoDB appeared ideal: serverless by design, pay-per-request pricing, and millisecond response times without any operational overhead. It enabled rapid iteration and quick deployment cycles, exactly what was needed in the early stages.

As the domain matured, however, relationships between data entities began to take shape. Features required cross-entity queries, transactional updates, and integrity constraints that went beyond DynamoDB’s strengths. The database that once enabled speed started introducing friction. That’s when the limits of the NoSQL approach became clear, and attention shifted toward a relational alternative better aligned with the system’s new realities.

Where DynamoDB Hurt Us (In Practice)

When we tried to model inherently relational needs with DynamoDB, we were fighting the database:

  • Relationships don’t map to partitions: Representing one-to-many (1-N) or many-to-many (N-N) relationships requires denormalization: arrays of IDs, adjacency lists, or duplicated attributes. Keeping these in sync pushes complexity into application code since there are no JOINs.
  • Filtering is post‑query: FilterExpression executes after Query, so it doesn’t reduce read volume. Pagination must be custom-implemented, and read costs remain unchanged..
  • Embedded aggregates slow change: Storing nested blobs to fetch “everything in one go” makes partial updates and targeted queries cumbersome.

Rule of thumb: If you keep inventing string encodings or composite attributes just to express relationships, you’re likely outside DynamoDB’s sweet spot.

Real‑world note: We briefly used arrays of IDs and string‑encoded relations to express many‑to‑many links. It worked, but it didn’t age well — a clear sign we needed SQL.

Why We Chose Postgres on Aurora Serverless v2

  • Relational superpowers: Foreign keys, constraints, transactions, indexes, CTEs, and of course JOINs. We replaced bespoke logic with explicit relations and let the database protect integrity.
  • Keep the serverless spirit: Aurora Serverless v2 scales capacity up/down within a defined range and charges accordingly. We didn’t have to pre‑allocate big instances for occasional spikes.
  • Developer simplicity with TypeORM: Entities mapped 1:1 to tables, repository interfaces, first‑class migrations, and easy containerized tests kept the code boring. We avoided heavy magic (e.g., pervasive lazy loading) and used TypeORM pragmatically.
  • Minimal JSONB: We favored normalized columns and join tables, only keeping small arrays of strings in JSONB when it genuinely simplified the model.

The Migration Playbook (Boring on Purpose)

We didn’t aim for zero downtime at all costs. The goal was to keep the process deterministic, well-tested, and easy to roll back.

1) Preparation

  • Schema mapping: keep the same domain concepts, but replace ad‑hoc encodings with proper relations. Example tables: core entities (e.g., items, users, categories) plus explicit join tables for many‑to‑many (e.g., item_categories(item_id, category_id)).
  • Migrations: TypeORM migrations (versioned, repeatable). In CI, we spin up Postgres in Docker and run repository tests end‑to‑end.
  • Indexes: obvious keys (PKs/FKs), plus supporting indexes for common filters.

2) Execution

  • Infra first: Deploy Aurora Serverless v2 (private subnets), security groups, and networking (Lambdas in VPC; VPC endpoints to external services as needed).
  • Migration Runner: After we push and CloudFormation updates the stack, we trigger a Lambda that replays DynamoDB items and inserts them into Postgres. Because we didn’t need continuous replication, a single well‑instrumented run was enough. We accepted a brief maintenance window.

3) Validation

  • Test coverage: Unit & repository tests over Postgres containers.
  • Data parity checks: counts per entity, foreign‑key integrity checks, spot checks on random samples.
  • Observability: log mismatches, keep an audit trail of migrated IDs, and make the Migration Runner idempotent.

How Hexagonal Architecture Simplified the Migration

The database migration was straightforward largely because of the system’s hexagonal architecture, a design pattern that isolates business logic from external systems such as databases, APIs, and queues. At its core, the application interacts with these systems through well-defined interfaces (called ports), while the concrete implementations behind them (the adapters) can change freely.

This separation meant repositories could be swapped without touching business rules. In practice, the change involved switching the DynamoDB adapter for a SQL one through dependency injection, with no rewrite required, just a different implementation behind the same interface.

For a deep dive on hexagonal architecture, watch the dedicated 50-minute workshop or read our article about our migration from Python to Typescript.

Before/After: one query, two mindsets

Before (DynamoDB, simplified):

1// Query items in a partition; FilterExpression doesn't reduce reads
2const params = {
3  TableName: 'YourTable',
4  KeyConditionExpression: 'PK = :pk',
5  ExpressionAttributeValues: {
6    ':pk': 'entity#id',
7    ':rel': 'related-123',
8    ':hidden': false,
9  },
10  ExpressionAttributeNames: {
11    '#rel': 'relatedIds',
12    '#hidden': 'hidden',
13  },
14  FilterExpression: 'contains(#rel, :rel) AND #hidden = :hidden',
15};
16const res = await client.query(params);

Note: Only KeyConditionExpression narrows reads; FilterExpression is post‑read, so heavy filtering often means more requests.

After (TypeORM, simplified):

1// items <-> related (many-to-many via join table)
2const results = await dataSource.getRepository(Item)
3  .createQueryBuilder("i")
4  .innerJoin("i.relations", "r")
5  .where("r.relatedId = :id", { id })
6  .orderBy("i.createdAt", "DESC")
7  .take(limit)
8  .skip(offset)
9  .getMany();

Note: ORM or SQL, the key change is expressing the relationship explicitly and letting the database do the heavy lifting.

Results

  • Fewer hacks, fewer bugs: Foreign keys and constraints caught mistakes early; no more string-concat filters.
  • Simpler repositories: Queries now rely on JOIN instead of building bespoke FilterExpression + pagination loops.
  • Performance gains: Perceived latency improved on endpoints that assemble cross-entity views.
  • Developers speed up: Small domain changes touch the table that actually owns the data.

Recognizing DynamoDB’s Ideal Use Cases

DynamoDB shines when access patterns are well defined, mostly key–value, and predictable low latency is a priority. It is excellent for high-write workloads, TTL-driven data, idempotent event logs, and simple catalogs. If the domain has few relational constraints and a robust single-table model can be designed (using GSIs or adjacency list patterns for many-to-many), DynamoDB can be unbeatable in cost, performance, and operational simplicity.

However, when relationships start to multiply, queries become ad hoc, or application logic begins compensating for missing joins and transactions, it may be time to consider a relational model. That shift often marks the point where SQL restores simplicity and long-term maintainability.

A Checklist You Can Copy

  1. Stable key–value access; huge writes or multi‑region? → DynamoDB.
  2. Relations (1‑N, N‑N), ad‑hoc queries, constraints/transactions? → SQL.
  3. TTL‑driven or event/log data dominates? → DynamoDB.
  4. Highly variable workload (bursts/spikes) and you want rich SQL without managing servers? → SQL (Aurora Serverless v2).

Rule of thumb: if you’re encoding relationships by hand, SQL might be the better choice.

Conclusion

Adopting DynamoDB was the right decision at the time. It enabled rapid development and quick iteration when flexibility mattered most. As the product matured and data relationships grew more complex, the limitations of a non-relational model became apparent. Transitioning to Aurora Serverless v2 restored the relational consistency and expressive querying needed to handle those new patterns, without giving up the operational benefits of a serverless environment.

For teams encountering similar turning points, the transition does not need to be abrupt. Start small: map the current schema, design one join table, and migrate a single read path to validate assumptions. Incremental steps provide valuable insight into the trade-offs and help build confidence in the direction of the migration. The resulting simplicity and transparency in data modeling often outweigh the effort required to make the change.