Step 1 — Setup and the schema
Every command to get from an empty folder to a running server with tables.
Topic 75 of 98 · 10 min to read · 50 min to build
After this topic you can
- Set up the shop module, .env file and database from an empty folder.
- Write a schema with stock checks, foreign keys and price in integer cents.
- Run the migration and confirm the server starts.
Every command for this step
terminal — run these in order
# 1. create the projectmkdir shop && cd shopgo mod init github.com/you/shop# 2. dependenciesgo get github.com/jackc/pgx/v5/stdlibgo get golang.org/x/crypto/bcryptgo get github.com/golang-jwt/jwt/v5go get github.com/joho/godotenv# 3. foldersmkdir -p cmd/apimkdir -p internal/core/{domain,port,service}mkdir -p internal/adapter/handler/http/{middleware,httpx}mkdir -p internal/adapter/storage/{postgres,file}mkdir -p internal/adapter/authmkdir -p internal/configmkdir -p migrations uploadsecho "uploads/" >> .gitignoreecho ".env" >> .gitignore# 4. empty files, so the layout is visible before you fill themtouch cmd/api/main.gotouch internal/config/config.go internal/adapter/storage/postgres/db.go internal/adapter/handler/http/httpx/json.gotouch migrations/001_init.up.sqltouch .env .env.example# 5. a databasedocker run -d --name shop-pg \ -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop \ -p 5432:5432 postgres:16# 6. the migration toolgo install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latestThe .env file
.env — fill this in, and copy it to .env.example with empty values
PORT=8080DATABASE_URL=postgres://postgres:secret@localhost:5432/shop?sslmode=disableJWT_SECRET=change-me-to-at-least-32-random-charactersUPLOAD_DIR=./uploadsMAX_UPLOAD_MB=5The schema
migrations/001_init.up.sql — paste this in
CREATE EXTENSION IF NOT EXISTS citext;CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, email CITEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'customer' CHECK (role IN ('customer', 'admin')), created_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE TABLE products ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', price_cents BIGINT NOT NULL CHECK (price_cents >= 0), stock INT NOT NULL DEFAULT 0 CHECK (stock >= 0), image_id BIGINT, active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE TABLE images ( id BIGSERIAL PRIMARY KEY, stored_name TEXT NOT NULL, mime TEXT NOT NULL, size_bytes BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE TABLE cart_items ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE CASCADE, quantity INT NOT NULL CHECK (quantity > 0), UNIQUE (user_id, product_id));CREATE TABLE orders ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id), total_cents BIGINT NOT NULL, status TEXT NOT NULL DEFAULT 'placed', created_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE TABLE order_items ( id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, product_id BIGINT NOT NULL REFERENCES products(id), name TEXT NOT NULL, price_cents BIGINT NOT NULL, quantity INT NOT NULL);CREATE INDEX ON orders (user_id, created_at DESC);shop/
cmd/
api/
main.gobuilds the adapters and plugs them into the core
internal/
core/THE APPLICATION — knows nothing about HTTP or SQL
domain/pure types and rules
product.goProduct, and what makes one valid
order.goOrder, OrderItem
cart.go
port/interfaces the core needs — the holes in the hexagon
repository.goProductRepository, OrderRepository
storage.goStorage — Save, Open, Remove for files
service/the rules, written against ports only
product.go
cart.go
order.gocheckout lives here
adapter/THE OUTSIDE WORLD — plugs into the ports
handler/driving adapters: things that call the core
http/
product.go
image.go
httpx/the JSON helpers from project two, still package httpx
json.go
middleware/
role.go
storage/driven adapters: things the core calls
postgres/
product.goimplements port.ProductRepository
order.go
file/
image.goimplements port.Storage — swap for s3/ later
auth/
jwt.go
config/
migrations/
Four decisions in that schema
| Decision | Why |
|---|---|
CHECK (stock >= 0) | The database refuses to go below zero. Even a buggy checkout cannot oversell: the UPDATE fails and the transaction rolls back. |
UNIQUE (user_id, product_id) on cart_items | Adding the same product twice updates the quantity rather than creating a second row. The constraint makes that the only possible outcome. |
order_items copies name and price_cents | This looks like duplication, and it is on purpose. An order must show what was paid then. If it pointed at the product, changing the price would silently rewrite history. |
role with a CHECK, not a boolean | A boolean is_admin cannot grow. A text column with a CHECK adds a third role later by changing one line. |
Watch out: Copying the price onto the order line is an easy decision to miss when you design a shop schema. Without it, a price change rewrites every past order and your accounts stop adding up.
Run the migration and check it
terminal
# apply itmigrate -path migrations -database "$DATABASE_URL" up# checkdocker exec -it shop-pg psql -U postgres -d shop -c '\dt'docker exec -it shop-pg psql -U postgres -d shop -c '\d products'# make yourself an admin later with:# UPDATE users SET role = 'admin' WHERE email = 'you@example.com';Copy the shared files from project two
The config package, the database pool, the JSON helpers, the user package and the auth middleware are the same as project two. Copy them across rather than retyping them. Reusing your own earlier work is part of the point of having built it.
terminal
# The same code, but it moves to where this architecture expects it.cp ../expenses/internal/config/config.go internal/config/cp ../expenses/internal/database/db.go internal/adapter/storage/postgres/cp ../expenses/internal/httpx/json.go internal/adapter/handler/http/httpx/cp ../expenses/internal/middleware/auth.go internal/adapter/handler/http/middleware/cp ../expenses/internal/user/user.go internal/core/domain/user.gocp ../expenses/internal/user/service.go internal/core/service/user.gocp ../expenses/internal/user/repository.go internal/adapter/storage/postgres/user.gocp ../expenses/internal/user/handler.go internal/adapter/handler/http/user.go# then fix the module path in every copied filegrep -rl 'github.com/you/expenses' internal/ | xargs sed -i 's|github.com/you/expenses|github.com/you/shop|g'go mod tidygo build ./...Confirm it runs
terminal
go run ./cmd/api# in another terminalcurl -s localhost:8080/healthzcurl -s -X POST localhost:8080/v1/auth/register \ -d '{"name":"Admin","email":"admin@shop.test","password":"password123"}'# promote that user to admindocker exec -it shop-pg psql -U postgres -d shop \ -c "UPDATE users SET role='admin' WHERE email='admin@shop.test';"The short version
- Let the database enforce stock, quantities and roles with CHECK constraints.
- Copy the price and name onto the order line, so history does not change when a product does.
- A
roletext column with a CHECK beats a boolean, because a third role costs one line. - Reuse the config, database, httpx, user and middleware packages you already built.
Remember: An order records what was true at the time it was placed. Anything it points at can change; anything it copies cannot.
Build this before moving on
Run every command in this step, apply the migration, register a user, and promote them to admin in psql. Then try to insert a product with negative stock and read the error.Then try these in your own editor
- 1Insert a
roleofsuperuserand watch the CHECK reject it. - 2Add the same product to a cart twice and see the UNIQUE constraint stop the second row.
- 3Write the
001_init.down.sqlthat undoes this migration cleanly. - 4Work out what breaks if
order_itemspointed at the product instead of copying its price.
Official documentation