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 projectmkdir expenses && cd expensesgo mod init github.com/you/expenses# 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. folders and filesmkdir -p cmd/api internal/{config,database,httpx,middleware,user,expense} migrationstouch cmd/api/main.go internal/config/config.go internal/database/db.gotouch internal/httpx/json.go migrations/001_init.up.sqltouch .env .env.exampleprintf '.env\n' >> .gitignore# 4. a databasedocker run -d --name exp-pg \ -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=expenses \ -p 5432:5432 postgres:16# 5. apply the schemadocker exec -i exp-pg psql -U postgres -d expenses < migrations/001_init.up.sql# 6. check itdocker 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 worksgo run ./cmd/apiJWT_SECRET=short go run ./cmd/api # must refuse to startWhat 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 configimport ( "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}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=8080DATABASE_URL=postgres://postgres:secret@localhost:5432/expenses?sslmode=disableJWT_SECRET=replace-me-with-32-or-more-random-charactersFile 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
| Decision | Why |
|---|---|
amount_cents BIGINT, not a decimal or float | Money 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 CASCADE | An 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 drivergo get golang.org/x/crypto/bcrypt # password hashinggo get github.com/golang-jwt/jwt/v5 # tokensgo get github.com/joho/godotenv # .env in developmentThe 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
- 1Try inserting an expense with a
user_idthat does not exist and read the error. - 2Try inserting a negative amount and watch the CHECK constraint reject it.
- 3Insert
A@x.comthena@x.comand see CITEXT treat them as the same email. - 4Set
JWT_SECRETto five characters and confirm startup fails with a clear message.
Official documentation