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:
| 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) |
-
Schema design is cleaner:
- Uses
id/versioncolumns with comments explaining DynamoDB mapping - Proper
expires_atTIMESTAMP for TTL (vs rawttl BIGINT) - Keyset pagination on
(status, id)- correct for UUID ordering - Partial index
WHERE expires_at IS NOT NULL- efficient TTL cleanup
- Uses
-
TTL handling is implemented:
- Has
StartTTLCleanup()goroutine with OTel metrics feature/postgresql-migration-phase1just has a reservedttl BIGINTcolumn
- Has
-
Infrastructure is production-ready:
- RDS with managed credentials + ExternalSecrets
- IAM database auth enabled
- CloudWatch log exports
-
Schema uses
pk/skdirectly:- Column naming is confusing (
pk UUIDinstead ofid) - Has
UNIQUE(pk, sk)constraint which is redundant with PK - Uses
created_at DESCin index which may not be optimal for keyset pagination
- Column naming is confusing (
-
Missing TTL implementation:
ttl BIGINT, -- Reserved for future pg_cron TTL implementation
This is incomplete - records will accumulate.
-
Clean factory pattern:
type BackendType string const ( BackendDynamoDB = "dynamodb" BackendPostgres = "postgres" BackendDual = "dual" BackendDualPostgres = "dual-postgres" )
Single
NOTIFICATION_DB_BACKENDenv var controls everything. -
Explicit interface in separate file:
repository_interface.gowith clear documentation- Compile-time interface checks
-
No handler changes needed:
- Factory injects the correct repo; handler is unchanged
-
Three separate env vars:
POSTGRES_ENABLED DUAL_WRITE_ENABLED PRIMARY_DATABASE
More config surface area = more failure modes.
-
Exports interface from handler package:
- Changes handler to use
notifications.RepositoryInterface - Couples packages more tightly
- Changes handler to use
-
Uses sqlx + pgx stdlib adapter:
- Extra abstraction layer (pgxpool → stdlib → sqlx)
migrate-dynamodb-postgresuses pgxpool directly
-
Single config knob:
NOTIFICATION_DB_BACKEND=dualvs multiple boolean flags
-
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(...) }
-
OTel tracing built-in:
- Traces dual-write operations
feature/postgresql-migration-phase1has no tracing on dual-write
- Slightly smaller diff (fewer lines)
- Self-contained in one commit (easier to revert)
- But deletes openspec documentation which may be unintentional
| 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 |
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)")