Stage 7 · Master
Phase 2 — Organization Service
Migrations
Turn the schema design into a real, reversible golang-migrate migration, and extend the Phase 1 Makefile to run it.
Why golang-migrate, Not an ORM's Auto-Migrate
An ORM's auto-migrate compares Go structs against the live database and generates DDL to reconcile them — convenient in a demo, risky in a service where a struct field rename should never silently trigger a column rename against production data. golang-migrate instead requires an explicit, hand-written, reviewable pair of SQL files for every change — one .up.sql, one .down.sql — tracked in a schema_migrations table it manages automatically, which makes re-running the same migration twice a safe no-op instead of a duplicate-object error.
The Up/Down Contract as a File Pair
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE organizations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
slug text NOT NULL,
registration_number text NOT NULL,
contact_email text NOT NULL,
contact_phone text,
address_line1 text NOT NULL,
address_line2 text,
city text NOT NULL,
state text NOT NULL,
postal_code text NOT NULL,
country text NOT NULL,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended', 'archived')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX organizations_slug_key ON organizations (slug);
CREATE INDEX organizations_status_idx ON organizations (status);
gen_random_uuid() comes from the pgcrypto extension, enabled defensively with IF NOT EXISTS since this is the first migration to need it. The matching .down.sql, shown next, drops exactly what this file created, in dependency order.
DROP TABLE IF EXISTS organizations;
Indexes disappear automatically with their table, so only the table itself needs an explicit drop. golang-migrate identifies this file as version 00001's rollback purely by its shared numeric prefix and its .down.sql suffix — the filename is the entire contract; nothing inside the file marks it as a Down step.
golang-migrate's pgx/v5 driver registers itself under the pgx5:// URL scheme, not postgres:// or postgresql://. ORGANIZATION_DATABASE_URL stays a postgres:// DSN everywhere pgxpool.New reads it — the app never changes — but any -database argument passed to the migrate binary needs that same connection string with its scheme swapped to pgx5://. The Makefile target below does that swap with a small piece of shell parameter expansion so the underlying env var never has to exist in two forms.
Extending the Phase 1 Makefile for Migration Commands
migrate-up:
migrate -path services/organization/migrations -database "pgx5://$${ORGANIZATION_DATABASE_URL#postgres://}" up
migrate-down:
migrate -path services/organization/migrations -database "pgx5://$${ORGANIZATION_DATABASE_URL#postgres://}" down 1
migrate-version:
migrate -path services/organization/migrations -database "pgx5://$${ORGANIZATION_DATABASE_URL#postgres://}" version
$${ORGANIZATION_DATABASE_URL#postgres://} is POSIX shell parameter expansion, not a Make variable — the doubled $$ escapes Make's own $-substitution so the shell sees a literal $, then that shell strips a leading postgres:// before pgx5:// is prepended back on. migrate-down passes an explicit 1 so a single make migrate-down always rolls back exactly one version, matching the step-at-a-time behavior this course relies on; bare down would otherwise prompt for confirmation before undoing every migration. migrate-version prints the single currently-applied version number (and whether it is dirty) without changing anything — the closest golang-migrate equivalent to asking 'what's pending', since the CLI has no separate status subcommand. Appended to the existing Makefile from Phase 1 — every earlier target (tidy, build, test, vet, lint, run-organization, compose-*) stays exactly as it was.
Why Running This Migration Twice Doesn't Break Anything
golang-migrate records the single highest applied version — plus a dirty flag — in a schema_migrations table it manages automatically. There is no per-migration history row; there is exactly one row, moved forward or back as up and down run. Running make migrate-up a second time sees that 00001 is already the recorded version and applies nothing — contrast this with running the raw SQL file's CREATE TABLE statement directly through psql a second time, which fails outright because Postgres refuses to create a table that already exists.
ERROR: relation "organizations" already exists
Running the Real Migration and Inspecting the Result
Applied exercise
Run make migrate-up a second time and confirm golang-migrate treats it as a no-op
The lesson claims golang-migrate's tracking table makes a repeat run safe. Confirm it directly, in contrast to the raw-SQL failure shown above.
- Run make migrate-up again, immediately after the successful run above.
- Record migrate's exact output for the already-applied migration (it should differ noticeably from the raw psql error you saw earlier — expect no output at all, since there's nothing pending).
- Run make migrate-down followed by make migrate-up once more, and confirm \dt shows the table present again afterward.
Deliverable
A short transcript comparing migrate's repeat-run output against the raw psql duplicate-table error from this lesson.
Completion checks
- The second make migrate-up exits successfully (status 0) with no pending migrations applied, unlike the raw SQL demonstration.
- After migrate-down then migrate-up, \dt shows organizations present again.
What specifically allows `make migrate-up` to be run twice in a row safely, when running the equivalent raw CREATE TABLE statement twice fails?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.