Dev Skills

Project 2 — Expense tracker: what we are building

A money app with accounts, which is why this is where login belongs.

Topic 65 of 98 · 5 min to read · 45 min to build
After this topic you can
  • Explain the package-by-feature architecture and how it differs from package by layer.
  • Explain the expense tracker's endpoints and data model.
  • Explain the difference between authentication and authorisation.

What you will build, and what it looks like when done

An API where people sign up, log in, record what they spend, and get a monthly report. Each person sees only their own data. Here is the finished thing in a terminal.

terminal — the finished project
$ curl -s -X POST localhost:8080/v1/auth/login \
-d '{"email":"ana@example.com","password":"correct horse battery"}'
{"access_token":"eyJhbGciOi...","refresh_token":"9f2c..."}
$ curl -s -X POST localhost:8080/v1/expenses -H "Authorization: Bearer $TOKEN" \
-d '{"amount_cents":1250,"category":"food","spent_on":"2026-09-14"}'
{"id":41,"amount_cents":1250,"category":"food","spent_on":"2026-09-14"}
$ curl -s localhost:8080/v1/reports/monthly?month=2026-09-01 -H "Authorization: Bearer $TOKEN"
[{"category":"food","total_cents":18400,"count":15}]

Why an expense tracker

The URL shortener had no users. Anyone could create a link and anyone could list them. That was fine for learning one service end to end. It is not fine for anything holding personal data.

An expense tracker records what somebody spent, on what, and when. It is a good second project because the requirement writes itself: I must never see your expenses, and you must never see mine. Everything about accounts and login exists to make that sentence true.

The architecture: package by feature

Project 1 used a layered layout: a folder per kind of code. This project uses a layout many Go teams settle on once a service grows: package by feature, sometimes called package-by-domain or vertical slicing.

The difference is what a folder is named after. Layered folders are named after a job (store, service). Feature folders are named after a thing your product has (user, expense), and everything about that thing lives inside: its type, its rules, its SQL, its HTTP handlers, its tests.

the same code, two ways of grouping it
LAYERED (project 1) BY FEATURE (this project)
internal/ internal/
model/ user/
link.go user.go the type
store/ service.go the rules
postgres.go repository.go the SQL
service/ handler.go the HTTP
shortener.go token.go
transport/http/ expense/
handler.go expense.go
service.go
repository.go
handler.go
one CONCEPT split across one FOLDER holds one concept,
four folders top to bottom

Why the change matters here

The shortener had one concept, so a folder per layer was clear. This project has three (users, expenses and sessions), and that is where layered layouts start to hurt.

With layers, adding expenses means editing model, store, service and transport. Every folder grows with every feature, no feature can be read in one place, and sooner or later two packages need each other, which Go rejects as an import cycle.

With feature folders, adding expenses is one new folder. Removing it is deleting that folder and its wiring in main.go. That is a good test of the layout.

The layers did not disappear

Package-by-feature does not throw the layers away. It turns them into files inside each folder, so you keep the separation and gain locality.

  • <name>.go — the type, its request shapes, its errors. Knows nothing outside its package.
  • repository.go — SQL and nothing else. Knows database/sql.
  • service.go — the rules: what is allowed, in what order. Knows only a repository interface it declares itself.
  • handler.go — reading requests, writing responses. Knows net/http and the service.

The rules

  • Dependencies still point inward: handler → service → repository. Never the reverse.
  • A feature package should not import another feature package. If expense needs users, it takes an interface, or main wires them together. Feature-to-feature imports rebuild the tangle you were avoiding.
  • Shared, feature-neutral code gets its own package — config, database, httpx, middleware. If it belongs to no feature, it is infrastructure.
  • Name the package after the concept, singular: user, not users and not userservice.
Note: Use this when the service has more than about two concepts and is expected to grow, which describes most real services. It is a sensible default unless you have a specific reason to choose otherwise. Project 3 shows one such reason.

The endpoints

Method and pathWhat it doesNeeds a login?
POST /v1/auth/registercreate an accountno
POST /v1/auth/loginexchange email and password for a tokenno
POST /v1/auth/refreshget a fresh token without logging in againno (uses a refresh token)
POST /v1/auth/logoutend the sessionyes
GET /v1/mewho am Iyes
POST /v1/expensesrecord a spendyes
GET /v1/expenseslist my expenses, filtered and pagedyes
GET /v1/expenses/{id}one of my expensesyes
PATCH /v1/expenses/{id}change oneyes
DELETE /v1/expenses/{id}remove oneyes
GET /v1/reports/monthlytotals by category for a monthyes
Note: The paths start with /v1. Adding that now costs nothing. Adding it once other people use your API means every client has to change its URLs, or you serve both paths for a long time. Do it in every project from here on.

The data

Three tables. The important part is the line joining them: every expense points at exactly one user, and that link is what every query in this project will be built around.

the shape, in words
users ── one user ──┐
│ has many
expenses ←───────────────┘
each row stores user_id, so it belongs to exactly one person
refresh_tokens ← one per active session, so logout can end it

The steps

  • Step 1 — setup, config that fails loudly, and the database schema.
  • Step 2 — register: what a password hash is and why you never store the password.
  • Step 3 — login: what a token is, and issuing a JWT.
  • Step 4 — middleware: checking that token once, at the edge, for every protected route.
  • Step 5 — expenses CRUD, where every query is locked to the logged-in user.
  • Step 6 — filtering, paging and a monthly report.
  • Step 7 — refresh tokens and logout, so sessions can actually be ended.
  • Step 8 — the tests that prove one user cannot reach another user’s data.

Two words you will need

Authentication is *who are you*. Checking an email and password, and handing back a token that proves it for later requests.

Authorisation is *what are you allowed to do*. In this project it is one rule (you may only touch rows where user_id is yours), but it is a separate question. Treating "logged in" as "allowed to touch anything" is how the IDOR bugs in step 5 happen.

The short version
  • Accounts exist to make one sentence true: I must never see your data.
  • Authentication is who you are; authorisation is what you may do. Different questions.
  • Every expense row stores a user_id, and every query will use it.
  • Version the path (/v1) from the first commit.
Remember: Login is not a feature you bolt on. It is the reason the data model has a user_id column in it at all.
Build this before moving on
Write out the endpoint table and the three tables by hand. For each endpoint, write down what should happen if the caller is logged in as a different user.
Then try these in your own editor
  1. 1List three things that could go wrong if expenses had no user_id column.
  2. 2Decide what should happen when somebody registers with an email that already exists.
  3. 3Decide whether an expense amount should be stored as a float. Then look up why the answer is no.
Official documentation
Check yourselfTake the Project 2 — Expense tracker test