Dev Skills

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 project
mkdir shop && cd shop
go mod init github.com/you/shop
# 2. dependencies
go get github.com/jackc/pgx/v5/stdlib
go get golang.org/x/crypto/bcrypt
go get github.com/golang-jwt/jwt/v5
go get github.com/joho/godotenv
# 3. folders
mkdir -p cmd/api
mkdir -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/auth
mkdir -p internal/config
mkdir -p migrations uploads
echo "uploads/" >> .gitignore
echo ".env" >> .gitignore
# 4. empty files, so the layout is visible before you fill them
touch cmd/api/main.go
touch internal/config/config.go internal/adapter/storage/postgres/db.go internal/adapter/handler/http/httpx/json.go
touch migrations/001_init.up.sql
touch .env .env.example
# 5. a database
docker run -d --name shop-pg \
-e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop \
-p 5432:5432 postgres:16
# 6. the migration tool
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

The .env file

.env — fill this in, and copy it to .env.example with empty values
PORT=8080
DATABASE_URL=postgres://postgres:secret@localhost:5432/shop?sslmode=disable
JWT_SECRET=change-me-to-at-least-32-random-characters
UPLOAD_DIR=./uploads
MAX_UPLOAD_MB=5

The 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/
The hexagon, as folders. Everything under `core` is your application; everything under `adapter` is the world it talks to.

Four decisions in that schema

DecisionWhy
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_itemsAdding 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_centsThis 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 booleanA 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 it
migrate -path migrations -database "$DATABASE_URL" up
# check
docker 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.go
cp ../expenses/internal/user/service.go internal/core/service/user.go
cp ../expenses/internal/user/repository.go internal/adapter/storage/postgres/user.go
cp ../expenses/internal/user/handler.go internal/adapter/handler/http/user.go
# then fix the module path in every copied file
grep -rl 'github.com/you/expenses' internal/ | xargs sed -i 's|github.com/you/expenses|github.com/you/shop|g'
go mod tidy
go build ./...
That grep + sed is worth learning. Renaming a module path by hand across twenty files is how you miss one.

Confirm it runs

terminal
go run ./cmd/api
# in another terminal
curl -s localhost:8080/healthz
curl -s -X POST localhost:8080/v1/auth/register \
-d '{"name":"Admin","email":"admin@shop.test","password":"password123"}'
# promote that user to admin
docker 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 role text 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
  1. 1Insert a role of superuser and watch the CHECK reject it.
  2. 2Add the same product to a cart twice and see the UNIQUE constraint stop the second row.
  3. 3Write the 001_init.down.sql that undoes this migration cleanly.
  4. 4Work out what breaks if order_items pointed at the product instead of copying its price.
Check yourselfTake the Project 3 — Small shop test