Dev Skills

Step 1 — Setup, config, and the schema

Fail at startup rather than at 3am, and let the database enforce what it can.

Topic 66 of 98 · 5 min to read · 50 min to build
After this topic you can
  • Write a config loader that fails at startup when a required variable is missing.
  • Write a schema that uses constraints to enforce rules in the database.
  • Explain why money is stored as integer cents, not float.

Every command for this step

terminal — run these in order
# 1. create the project
mkdir expenses && cd expenses
go mod init github.com/you/expenses
# 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 and files
mkdir -p cmd/api internal/{config,database,httpx,middleware,user,expense} migrations
touch cmd/api/main.go internal/config/config.go internal/database/db.go
touch internal/httpx/json.go migrations/001_init.up.sql
touch .env .env.example
printf '.env\n' >> .gitignore
# 4. a database
docker run -d --name exp-pg \
-e POSTGRES_PASSWORD=secret -e POSTGRES_DB=expenses \
-p 5432:5432 postgres:16
# 5. apply the schema
docker exec -i exp-pg psql -U postgres -d expenses < migrations/001_init.up.sql
# 6. check it
docker exec -it exp-pg psql -U postgres -d expenses -c '\dt'
docker exec -it exp-pg psql -U postgres -d expenses -c '\d expenses'
# 7. run it, and prove the config check works
go run ./cmd/api
JWT_SECRET=short go run ./cmd/api # must refuse to start

What config means here

Configuration is everything that changes between your laptop and a server: the database address, the port, the secret used to sign tokens. It does not belong in the code: a secret committed once stays in your git history forever, and a new database address should not need a rebuild.

It comes from environment variables: values the operating system hands your process when it starts. Read them once, at startup, check them, and pass the result down. Nothing deeper in the program should ever call os.Getenv.

File 1 — internal/config/config.go

internal/config/config.go — create this file
package config
import (
"errors"
"os"
"github.com/joho/godotenv"
)
type Config struct {
Port string
DatabaseURL string
JWTSecret []byte
}
func Load() (*Config, error) {
_ = godotenv.Load() // loads .env in development; ignored in production
c := &Config{
Port: env("PORT", "8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
JWTSecret: []byte(os.Getenv("JWT_SECRET")),
}
// Check here, so a missing value stops the deploy instead of breaking a
// request later.
if c.DatabaseURL == "" {
return nil, errors.New("DATABASE_URL is required")
}
if len(c.JWTSecret) < 32 {
return nil, errors.New("JWT_SECRET must be at least 32 characters")
}
return c, nil
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
A short HMAC secret can be brute-forced offline from a single token, so it is checked rather than trusted. Refusing to start is the right response.

File 2 — .env.example

Commit an example with every key and no values. The real .env goes in .gitignore. This is how the next person knows what to set without you having to tell them.

.env.example — create this file, and gitignore .env
PORT=8080
DATABASE_URL=postgres://postgres:secret@localhost:5432/expenses?sslmode=disable
JWT_SECRET=replace-me-with-32-or-more-random-characters

File 3 — the schema

migrations/001_init.up.sql — create this file
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email CITEXT UNIQUE NOT NULL, -- CITEXT: A@x.com equals a@x.com
password_hash TEXT NOT NULL, -- the hash, never the password
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE expenses (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
amount_cents BIGINT NOT NULL CHECK (amount_cents > 0),
category TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
spent_on DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- the exact shape of "list my expenses, newest first"
CREATE INDEX ON expenses (user_id, spent_on DESC);

Three decisions in that schema worth explaining

DecisionWhy
amount_cents BIGINT, not a decimal or floatMoney in a float is wrong: 0.1 + 0.2 is not 0.3 in binary. Store whole cents as an integer and divide by 100 only when displaying dollars (or rupees, or euros).
REFERENCES users(id) ON DELETE CASCADEAn expense cannot point at a user who does not exist, and deleting a user removes their expenses. The database enforces it, so no Go code can forget.
CHECK (amount_cents > 0)A negative expense makes no sense here. A rule the database enforces cannot be bypassed by a bug in one handler.
Watch out: Never store money in a float64. Each rounding error is tiny, and nobody notices until totals stop matching. Use integer cents, or a proper decimal type.

Dependencies for the whole project

terminal
go get github.com/jackc/pgx/v5/stdlib # Postgres driver
go get golang.org/x/crypto/bcrypt # password hashing
go get github.com/golang-jwt/jwt/v5 # tokens
go get github.com/joho/godotenv # .env in development
Four, for a complete API with accounts. Everything else comes with Go.
The short version
  • Read config once at startup, check it, and refuse to start when something is missing.
  • Store money as whole cents (the smallest currency unit) in an integer. Never a float.
  • Let the database enforce what it can: foreign keys, cascades, unique and check constraints.
  • Index the query you will actually run: here, (user_id, spent_on DESC).
Remember: A rule the database enforces cannot be forgotten by a handler you write six months from now.
Build this before moving on
Create the config package, the .env.example, and the schema. Start Postgres, run the migration, and check the tables with \d expenses in psql. Then delete JWT_SECRET and confirm the app refuses to start.
Then try these in your own editor
  1. 1Try inserting an expense with a user_id that does not exist and read the error.
  2. 2Try inserting a negative amount and watch the CHECK constraint reject it.
  3. 3Insert A@x.com then a@x.com and see CITEXT treat them as the same email.
  4. 4Set JWT_SECRET to five characters and confirm startup fails with a clear message.
Check yourselfTake the Project 2 — Expense tracker test