Skip to content

Instantly share code, notes, and snippets.

@justinabrahms
Created January 5, 2026 16:26
Show Gist options
  • Select an option

  • Save justinabrahms/6639cf783709a2ac80df2cfb8b16360a to your computer and use it in GitHub Desktop.

Select an option

Save justinabrahms/6639cf783709a2ac80df2cfb8b16360a to your computer and use it in GitHub Desktop.

Compare these two implementations. Benefits/detriments of each. Look at this through the eyes of correctness, maintainability and simplicity. branch: migrate-dynamodb-postgres branch: feature/postgresql-migration-phase1

⏺ I'll fetch the details of both branches to compare their implementations.

⏺ mcp__github__get_commit(...) ⏺ mcp__github__get_commit(...) ⎿ [{'type': 'text', 'text': '{"sha":"fc6c639f2a74fb35af997b2b49524f1efcb1bba7","html_url":"https://github.com/ThriveMarket/notification-service/commit/fc6c639f2a74fb35af997b2b49524f1efcb1bba7","commit":{"message":"Fix duplicate string literal in test","author":{"name":"Justin Abrahms","email":"justin.abrahms@thrivemarket.com","date":"2026-01-03T06:54:29Z"},"committer":{"name":"Justin Abrahms","email":"justin.abrahms@thrivemarket.com","date":"2026-01-03T06:54:29Z"}},"author":{"login":"justinabrahms","id":3853,"profile_url":"https://github.com/justinabrahms","avatar_url":"https://avatars.githubusercontent.com/u/3853?v=4"},"committer":{"login":"justinabrahms","id":3853,"profile_url":"https://github.com/justinabrahms","avatar_url":"https://avatars.githubusercontent.com/u/3853?v=4"},"stats":{"additions":5,"deletions":3,"total":8},"files":[{"filename":"internal/notifications/repository_dual_test.go","status":"modified","additions":5,"deletions":3,"changes":8}]}'}]

⎿ [{'type': 'text', 'text': '{"sha":"82d65931a2cbcc96d3d55ff954b97a41b9e6ad40","html_url":"https://github.com/ThriveMarket/notification-service/commit/82d65931a2cbcc96d3d55ff954b97a41b9e6ad40","commit":{"message":"Add PostgreSQL support with dual-write capability (Phase 1)\n\nThis PR adds PostgreSQL as an alternative database backend with support\nfor dual-write during migration. Key changes:\n\nNew files:\n- internal/postgres/postgres.go - Connection pool with pgx/sqlx\n- internal/notifications/repository_postgres.go - PostgreSQL repository\n- internal/notifications/repository_composite.go - Dual-write wrapper\n- migrations/000001_create_notifications_table.up.sql - Schema\n- migrations/000001_create_notifications_table.down.sql - Rollback\n\nChanges:\n- main.go: Conditional PostgreSQL initialization with feature flags\n- handler: Use exported RepositoryInterface for better modularity\n- docker-compose: Add PostgreSQL service for local development\n- local.env: Add PostgreSQL and migration feature flag config\n\nFeature flags:\n- POSTGRES_ENABLED: Enable/disable PostgreSQL connection\n- DUAL_WRITE_ENABLED: Write to both DynamoDB and PostgreSQL\n- PRIMARY_DATABASE: Control which database handles reads (dynamodb/postgres)\n\nPhase 1 deploys with POSTGRES_ENABLED=false (no behavior change).\nSubsequent phases will enable dual-write and eventually switch primary.\n\n🤖 Generated with Claude Code\n\nCo-Authored-By: Claude Opus 4.5 \u003cnoreply@anthropic.com\u003e","author":{"name":"Thanh Lim","email":"thanh.lim@thrivemarket.com","date":"2025-12-31T20:57:41Z"},"committer":{"name":"Thanh Lim","email":"thanh.lim@thrivemarket.com","date":"2025-12-31T20:57:41Z"}},"author":{"login":"thanhlim","id":1195750,"profile_url":"https://github.com/thanhlim","avatar_url":"https://avatars.githubusercontent.com/u/1195750?v=4"},"committer":{"login":"thanhlim","id":1195750,"profile_url":"https://github.com/thanhlim","avatar_url":"https://avatars.githubusercontent.com/u/1195750?v=4"},"stats":{"additions":1649,"deletions":20,"total":1669},"files":[{"filename":"cmd/ns/main.go","status":"modified","additions":57,"deletions":2,"changes":59},{"filename":"configs/ns/local.env","status":"modified","additions":20,"deletions":1,"changes":21},{"filename":"docker-compose.yml","status":"modified","additions":23,"changes":23},{"filename":"go.mod","status":"modified","additions":9,"deletions":3,"changes":12},{"filename":"go.sum","status":"modified","additions":26,"deletions":4,"changes":30},{"filename":"internal/notifications/handler/handler.go","status":"modified","additions":2,"deletions":1,"changes":3},{"filename":"internal/notifications/handler/handler_dependencies.go","status":"modified","additions":1,"deletions":9,"changes":10},{"filename":"internal/notifications/repository_composite.go","status":"added","additions":232,"changes":232},{"filename":"internal/notifications/repository_composite_test.go","status":"added","additions":353,"changes":353},{"filename":"internal/notifications/repository_postgres.go","status":"added","additions":487,"changes":487},{"filename":"internal/notifications/repository_postgres_test.go","status":"added","additions":235,"changes":235},{"filename":"internal/postgres/postgres.go","status":"added","additions":161,"changes":161},{"filename":"migrations/000001_create_notifications_table.down.sql","status":"added","additions":6,"changes":6},{"filename":"migrations/000001_create_notifications_table.up.sql","status":"added","additions":37,"changes":37}]}'}]

⏺ Let me get a fuller picture by comparing each branch to main and looking at the key files.

⏺ Bash(git fetch origin migrate-dynamodb-postgres feature/postgresql-migration-phase...) ⏺ Bash(git log --oneline main..origin/feature/postgresql-migration-phase1 | head -20) ⎿ fc6c639 Fix duplicate string literal in test d77cc57 Add unit tests for repository factory and dual-write 44aab0c Fix qlty linter issues 65b6d9a Add CloudWatch Logs exports to RDS instance 81465fd Use RDS-managed credentials via ExternalSecrets 161f4f5 Implement PostgreSQL backend for notification storage

⎿ 82d6593 Add PostgreSQL support with dual-write capability (Phase 1)

⏺ Bash(git diff main..origin/migrate-dynamodb-postgres --stat) ⏺ Bash(git diff main..origin/feature/postgresql-migration-phase1 --stat) ⎿ README.md | 45 +- docker-compose.yml | 25 + go.mod | 13 +- go.sum | 26 +- internal/notifications/repository_dual.go | 159 ++++++ internal/notifications/repository_dual_test.go | 260 ++++++++++ internal/notifications/repository_factory.go | 211 ++++++++ internal/notifications/repository_factory_test.go | 192 +++++++ internal/notifications/repository_interface.go | 44 ++ internal/notifications/repository_postgres.go | 555 +++++++++++++++++++++ internal/notifications/repository_postgres_test.go | 170 +++++++ k8s/base/externalsecret-rds.yaml | 43 ++ k8s/base/kustomization.yaml | 1 + k8s/base/rollout.yaml | 3 + k8s/clusters/dev/externalsecret-rds-patch.yaml | 34 ++ k8s/clusters/dev/kustomization.yaml | 1 + k8s/clusters/prod/externalsecret-rds-patch.yaml | 34 ++ k8s/clusters/prod/kustomization.yaml | 1 + migrations/001_create_notifications_table.sql | 56 +++ .../changes/migrate-dynamodb-to-postgres/tasks.md | 48 +- [..snip.. 4 more lines]

⎿ cmd/ns/main.go | 59 ++- configs/ns/local.env | 21 +- docker-compose.yml | 23 + go.mod | 12 +- go.sum | 30 +- internal/notifications/handler/handler.go | 3 +- .../notifications/handler/handler_dependencies.go | 10 +- internal/notifications/repository_composite.go | 232 ++++++++++ .../notifications/repository_composite_test.go | 353 +++++++++++++++ internal/notifications/repository_postgres.go | 487 +++++++++++++++++++++ internal/notifications/repository_postgres_test.go | 235 ++++++++++ internal/postgres/postgres.go | 161 +++++++ .../000001_create_notifications_table.down.sql | 6 + .../000001_create_notifications_table.up.sql | 37 ++ .../changes/migrate-dynamodb-to-postgres/design.md | 224 ---------- .../migrate-dynamodb-to-postgres/proposal.md | 68 --- .../specs/notification-storage/spec.md | 125 ------ .../changes/migrate-dynamodb-to-postgres/tasks.md | 64 --- 18 files changed, 1649 insertions(+), 501 deletions(-)

⏺ Let me examine the core implementation files from each branch:

⏺ Bash(git show origin/migrate-dynamodb-postgres:internal/notifications/repository_f...) ⏺ Bash(git show origin/feature/postgresql-migration-phase1:internal/notifications/re...) ⎿ package notifications

import (
	"context"
	"os"
	"strconv"

	"github.com/newrelic/go-agent/v3/newrelic"
	"github.com/pkg/errors"
	"github.com/sirupsen/logrus"
)

const (
	dualWriteEnabledEnv = "DUAL_WRITE_ENABLED"
	primaryDatabaseEnv  = "PRIMARY_DATABASE"

	// Database type constants
	DatabaseTypeDynamoDB  = "dynamodb"
	DatabaseTypePostgres  = "postgres"
)
[..snip.. 212 more lines]

⎿ package notifications

import (
	"context"
	"fmt"
	"os"
	"time"

	"github.com/ThriveMarket/notification-service/internal/gobreaker"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/pkg/errors"
	"github.com/sirupsen/logrus"
)

// BackendType represents the storage backend to use.
type BackendType string

const (
	// BackendDynamoDB uses DynamoDB only (legacy).
	BackendDynamoDB BackendType = "dynamodb"
[..snip.. 191 more lines]

⏺ Bash(git show origin/migrate-dynamodb-postgres:internal/notifications/repository_d...) ⏺ Bash(git show origin/migrate-dynamodb-postgres:internal/notifications/repository_p...) ⎿ package notifications

import (
	"context"

	"github.com/newrelic/go-agent/v3/newrelic"
	"github.com/sirupsen/logrus"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/trace"
)

const (
	attrDualReadFromPostgres = "dual.read_from_postgres"
)

// DualWriteRepository writes to both DynamoDB and PostgreSQL during migration.
// Reads are controlled by the readFromPostgres flag.
type DualWriteRepository struct {
	dynamo           NotificationRepository
[..snip.. 139 more lines]

⎿ package notifications

import (
	"context"
	"encoding/json"
	"time"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/newrelic/go-agent/v3/newrelic"
	"github.com/pkg/errors"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/codes"
	"go.opentelemetry.io/otel/metric"
	"go.opentelemetry.io/otel/trace"
)

// OpenTelemetry span attribute keys
[..snip.. 180 more lines]

⏺ Bash(git show origin/migrate-dynamodb-postgres:migrations/001_create_notifications...) ⏺ Bash(git show origin/feature/postgresql-migration-phase1:migrations/000001_create_...) ⎿ -- Create notifications table CREATE TABLE IF NOT EXISTS notifications ( pk UUID PRIMARY KEY DEFAULT gen_random_uuid(), sk VARCHAR(10) NOT NULL DEFAULT '1.0', status VARCHAR(20) NOT NULL CHECK (status IN ('retry', 'success', 'failed')), event_name VARCHAR(255) NOT NULL, event_type VARCHAR(50), client VARCHAR(100) NOT NULL, payload JSONB NOT NULL DEFAULT '{}', responses JSONB NOT NULL DEFAULT '{}', attempts INT NOT NULL DEFAULT 0, max_attempts INT NOT NULL DEFAULT 5, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), ttl BIGINT, -- Reserved for future pg_cron TTL implementation UNIQUE(pk, sk) );

-- Index for status-based queries (replaces DynamoDB GSI status_index)
CREATE INDEX idx_notifications_status ON notifications(status);
[..snip.. 17 more lines]

⎿ -- Notifications table schema -- Migrated from DynamoDB, maintains API compatibility via column naming

CREATE TABLE IF NOT EXISTS notifications (
    -- Primary key maps to DynamoDB 'pk' field (exposed in API as primary_key)
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Version field maps to DynamoDB 'sk' field (always "1.0", kept for API compat)
    version VARCHAR(10) NOT NULL DEFAULT '1.0',

    -- Notification status: success, retry, failed
    status VARCHAR(20) NOT NULL,

    -- Event metadata
    event_name VARCHAR(255) NOT NULL,
    event_type VARCHAR(50),
    client VARCHAR(50) NOT NULL,

    -- Flexible JSON storage for provider-specific data
    payload JSONB,
[..snip.. 36 more lines]

⏺ Bash(git show origin/migrate-dynamodb-postgres:tf/regional/rds.tf | head -80) ⏺ Bash(git show origin/feature/postgresql-migration-phase1:cmd/ns/main.go | head -100) ⎿ # RDS PostgreSQL instance for notification storage # Replaces DynamoDB for simpler, more predictable operations

# Security group for RDS access from EKS pods
resource "aws_security_group" "rds" {
  name        = "${local.application}-rds"
  description = "Security group for ${local.application} RDS PostgreSQL"
  vpc_id      = var.vpc_id

  ingress {
    description     = "PostgreSQL from EKS pods"
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = var.eks_security_group_ids
  }

  # No egress rules needed - security groups are stateful and RDS doesn't initiate outbound connections

  tags = {
[..snip.. 60 more lines]

⎿ package main

import (
	"context"
	"net"
	"net/http"
	"time"

	"github.com/ThriveMarket/notification-service/internal/attentive"
	iaws "github.com/ThriveMarket/notification-service/internal/aws"
	log "github.com/ThriveMarket/notification-service/internal/logger"
	"github.com/ThriveMarket/notification-service/internal/newrelic"
	"github.com/ThriveMarket/notification-service/internal/notifications"
	"github.com/ThriveMarket/notification-service/internal/notifications/handler"
	"github.com/ThriveMarket/notification-service/internal/otel"
	"github.com/ThriveMarket/notification-service/internal/postgres"
	"github.com/ThriveMarket/notification-service/internal/router"
	"github.com/ThriveMarket/notification-service/internal/sailthru"
	_ "github.com/ThriveMarket/notification-service/web/statik"
	awsr "github.com/aws/aws-sdk-go-v2/aws"
[..snip.. 80 more lines]

⏺ Bash(git show origin/feature/postgresql-migration-phase1:cmd/ns/main.go | tail -100) ⏺ Bash(git diff main..origin/migrate-dynamodb-postgres -- cmd/ns/main.go 2>/dev/null...) ⎿

⎿ }

		finalRepo = compositeRepo

		logger.WithFields(map[string]interface{}{
			"primary_database": compositeRepo.GetPrimaryDatabaseName(),
			"dual_write":       compositeRepo.IsDualWriteActive(),
		}).Info("Composite repository initialized")
	} else {
		logger.Info("PostgreSQL is disabled, using DynamoDB only")
	}

	// create main http routes for the micro service
	r, err := router.NewRouter(router.RouterDeps{
		NewRelic: nr,
	})
	if err != nil {
		logger.Fatal(err)
	}
	healthEndpoint := router.NewHealth(router.HealthDeps{
[..snip.. 80 more lines]

⏺ Bash(git show origin/migrate-dynamodb-postgres:README.md | head -100) ⏺ Bash(git show origin/feature/postgresql-migration-phase1:internal/postgres/postgre...) ⎿ package postgres

import (
	"context"
	"fmt"
	"os"
	"strconv"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/jackc/pgx/v5/stdlib"
	"github.com/jmoiron/sqlx"
	"github.com/pkg/errors"
)

const (
	hostEnv               = "POSTGRES_HOST"
	portEnv               = "POSTGRES_PORT"
	userEnv               = "POSTGRES_USER"
	passwordEnv           = "POSTGRES_PASSWORD"
[..snip.. 141 more lines]

⎿ # Notification Service

A centralized microservice for reliable, transactional message delivery via
Sailthru and Attentive.

## Value Proposition

The Notification Service provides a **reliable messaging layer** between Thrive
Market's backend systems and external notification providers (Sailthru,
Attentive). It solves critical problems that direct API calls cannot:

### Why Use Notification Service?

**✅ Use Notification Service When:**

- Sending **transactional SMS/email** (order updates, security codes, product
  alerts)
- Requiring **delivery guarantees** and automatic retries
- Needing **rate limiting** and throttling for SMS compliance
- Wanting **centralized logging** and monitoring of message delivery
[..snip.. 80 more lines]

⏺ Bash(git show origin/migrate-dynamodb-postgres:internal/notifications/repository_i...) ⏺ Bash(git diff main..origin/feature/postgresql-migration-phase1 -- internal/notific...) ⎿ package notifications

import (
	"context"

	"github.com/newrelic/go-agent/v3/newrelic"
)

// NotificationRepository defines the interface for notification storage operations.
// Implementations include DynamoDBRepository, PostgresRepository, and DualWriteRepository.
type NotificationRepository interface {
	// GetNotificationsByStatus retrieves notifications filtered by status with pagination.
	// Parameters:
	//   - status: Filter by notification status (retry, success, failed)
	//   - lastEvaluatedPk: Pagination cursor (primary key from previous page)
	//   - lastEvaluatedSk: Pagination cursor (sort key from previous page, always "1.0")
	//   - attributes: Optional list of attributes to return (empty = all)
	//   - limit: Maximum number of records to return
	// Returns NotificationRecordsResponse with notifications and pagination info.
	GetNotificationsByStatus(ctx context.Context, txn *newrelic.Transaction, status, lastEvaluatedPk, lastEvaluatedSk string, attributes []string, limit int32) (NotificationRecordsResponse, error)
[..snip.. 24 more lines]

⎿ diff --git a/internal/notifications/handler/handler.go b/internal/notifications/handler/handler.go index 1be9cb3..75997b3 100644 --- a/internal/notifications/handler/handler.go +++ b/internal/notifications/handler/handler.go @@ -1,6 +1,7 @@ package handler

 import (
+	"github.com/ThriveMarket/notification-service/internal/notifications"
 	"github.com/ThriveMarket/notification-service/internal/otel"
 	"github.com/gin-gonic/gin"
 	"github.com/sirupsen/logrus"
@@ -15,7 +16,7 @@ const (
 )
 
 type Handler struct {
-	db        repositoryInterface
+	db        notifications.RepositoryInterface
 	logger    *logrus.Logger
 	sailthru  sailthruInterface
[..snip.. 30 more lines]

⏺ Now I have a complete picture. Here's the comparison:


Branch Comparison: migrate-dynamodb-postgres vs feature/postgresql-migration-phase1

Overview

Aspect migrate-dynamodb-postgres feature/postgresql-migration-phase1
Commits 6 (incremental) 1 (monolithic)
Lines changed +2,043 / -46 +1,649 / -501
Infrastructure ✅ Terraform RDS, ExternalSecrets, k8s ❌ None (local docker only)
Deletes specs ❌ No ✅ Yes (removes openspec docs)

Correctness

migrate-dynamodb-postgres ✅ Better

  1. Schema design is cleaner:

    • Uses id/version columns with comments explaining DynamoDB mapping
    • Proper expires_at TIMESTAMP for TTL (vs raw ttl BIGINT)
    • Keyset pagination on (status, id) - correct for UUID ordering
    • Partial index WHERE expires_at IS NOT NULL - efficient TTL cleanup
  2. TTL handling is implemented:

    • Has StartTTLCleanup() goroutine with OTel metrics
    • feature/postgresql-migration-phase1 just has a reserved ttl BIGINT column
  3. Infrastructure is production-ready:

    • RDS with managed credentials + ExternalSecrets
    • IAM database auth enabled
    • CloudWatch log exports

feature/postgresql-migration-phase1 ⚠️ Issues

  1. Schema uses pk/sk directly:

    • Column naming is confusing (pk UUID instead of id)
    • Has UNIQUE(pk, sk) constraint which is redundant with PK
    • Uses created_at DESC in index which may not be optimal for keyset pagination
  2. Missing TTL implementation:

    ttl BIGINT,  -- Reserved for future pg_cron TTL implementation

    This is incomplete - records will accumulate.


Maintainability

migrate-dynamodb-postgres ✅ Better

  1. Clean factory pattern:

    type BackendType string
    const (
        BackendDynamoDB     = "dynamodb"
        BackendPostgres     = "postgres"
        BackendDual         = "dual"
        BackendDualPostgres = "dual-postgres"
    )

    Single NOTIFICATION_DB_BACKEND env var controls everything.

  2. Explicit interface in separate file:

    • repository_interface.go with clear documentation
    • Compile-time interface checks
  3. No handler changes needed:

    • Factory injects the correct repo; handler is unchanged

feature/postgresql-migration-phase1 ⚠️ Issues

  1. Three separate env vars:

    POSTGRES_ENABLED
    DUAL_WRITE_ENABLED
    PRIMARY_DATABASE

    More config surface area = more failure modes.

  2. Exports interface from handler package:

    • Changes handler to use notifications.RepositoryInterface
    • Couples packages more tightly
  3. Uses sqlx + pgx stdlib adapter:

    • Extra abstraction layer (pgxpool → stdlib → sqlx)
    • migrate-dynamodb-postgres uses pgxpool directly

Simplicity

migrate-dynamodb-postgres ✅ Better

  1. Single config knob:

    • NOTIFICATION_DB_BACKEND=dual vs multiple boolean flags
  2. Async writes are symmetric:

    if r.readFromPostgres {
        pk, sk, err = r.postgres.Insert(...)
        go r.insertToDynamo(...)
    } else {
        pk, sk, err = r.dynamo.Insert(...)
        go r.insertToPostgres(...)
    }
  3. OTel tracing built-in:

    • Traces dual-write operations
    • feature/postgresql-migration-phase1 has no tracing on dual-write

feature/postgresql-migration-phase1

  1. Slightly smaller diff (fewer lines)
  2. Self-contained in one commit (easier to revert)
  3. But deletes openspec documentation which may be unintentional

Summary

Criteria Winner
Correctness migrate-dynamodb-postgres - TTL works, schema is cleaner
Maintainability migrate-dynamodb-postgres - factory pattern, single config
Simplicity migrate-dynamodb-postgres - fewer env vars, symmetric logic

Recommendation

Use migrate-dynamodb-postgres as the base. It's more production-ready with infrastructure included. The incremental commits also make the change easier to review and bisect if issues arise.

If merging feature/postgresql-migration-phase1, the openspec doc deletions look accidental and should be reverted.

share /share

⏺ Bash(~/.claude/scripts/share-session.sh "$(pwd)")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment