Migrating from Cassandra to PostgreSQL with Neon
When I first built this website, I chose Apache Cassandra — partly because it was the database I was using in my larger e-commerce platform, and partly because the dual-mode adapter (local Docker vs. cloud Astra DB) made for an interesting architectural showcase.
But as the site matured, the mismatch between tool and task became clear. A portfolio website with two tables doesn't need a distributed NoSQL database. It needs something simple, reliable, and cheap to host.
Why PostgreSQL?
- Schema enforcement:
NOT NULLconstraints,DEFAULTvalues, andON CONFLICT DO UPDATE(true upserts) replace Cassandra's implicit-null, last-write-wins model. - Native arrays: PostgreSQL
TEXT[]replaces CassandraSET<TEXT>, and the driver returns real JavaScript arrays — no moreSetconversion logic. - Standard SQL:
ORDER BYin queries,WHERE featured = truewithoutALLOW FILTERING, and$1, $2parameterized queries. - Simpler cloud story: Neon gives you a connection string. That's it. No Secure Connect Bundles, no base64-encoded zip files, no
USE_ASTRAtoggle.
The Migration
The old architecture had two database client implementations — LocalCassandraClient and AstraDbClient — behind a DbClient interface, switched by an environment variable:
// Before: dual-mode Cassandra adapter
const useAstra = process.env.USE_ASTRA === 'true';
dbClient = useAstra ? new AstraDbClient() : new LocalCassandraClient();
The new architecture is a single client using @neondatabase/serverless. The same driver talks to a local Docker PostgreSQL instance (via the Neon HTTP proxy) in development and Neon's serverless endpoint in production:
// After: single connection string, one driver
import { neon } from '@neondatabase/serverless';
const sql = neon(process.env.POSTGRES_URL!);
The entire db.ts file went from 101 lines to 65.
What Changed
| Layer | Before | After |
|---|---|---|
| Driver | cassandra-driver | @neondatabase/serverless |
| Local DB | Cassandra 4.1 container | PostgreSQL 16 + Neon HTTP proxy |
| Cloud DB | DataStax Astra DB (SCB bundle) | Neon (connection string) |
| Schema | CQL (init.cql) | SQL (init.sql, auto-applied by Docker) |
| UUIDs | types.Uuid.random() | crypto.randomUUID() |
| Arrays | Set<string> → manual conversion | TEXT[] → native arrays |
| Upserts | Cassandra INSERT (implicit upsert) | ON CONFLICT DO UPDATE |
What Stayed the Same
The DbClient interface didn't change. The repositories still call db.execute(query, params). The portfolio pages, contact form, and health endpoint all work exactly as before — they never knew which database was behind the adapter.
That's the value of a clean abstraction layer: swapping the entire database engine was a one-day change with zero modifications to any page component.