Dev Skills

Practice 3 — CRUD, one folder per feature

The final layout, and the one the rest of this track is built on.

Topic 57 of 98 · 10 min to read · 50 min to build
After this topic you can
  • Build a feature folder with model, repository, service and handler files.
  • Explain which layer owns HTTP, rules and storage, and why dependencies point one way.
  • Wire the layers by hand in main.go without a DI framework.

The problem this one solves

In practice project two, everything about tasks was split across internal/task and internal/api. That is fine with one feature. Add users, orders and payments and every folder holds a bit of everything, so no feature can be read, or removed, in one place.

Grouping by feature flips it. One folder holds one business concept and everything that concept needs: its type, its rules, its SQL, its HTTP handlers, its tests.

The layers from practice project two do not go away. They become four files inside each feature folder, which is why building the layered version first was worth it.

The four files, and what each one is responsible for

FileResponsible forKnows about
model.goThe type itself and the shape of incoming requests. Field validation lives here.nothing outside its own package
repository.goGetting data in and out of storage. SQL and nothing else.database/sql
service.goThe rules. What is allowed, what happens in what order, what counts as an error.the repository (through an interface it defines itself)
handler.goHTTP only. Read the request, call the service, write the response.net/http and the service

Two words worth defining properly, because they get used loosely:

A repository is the piece that stores and retrieves things. Its whole job is to hide *how* data is kept, so the rest of the program asks for "user 5" rather than writing SQL.

A service is where the rules live. It is the part that says a password must be hashed before saving, that an email is lowercased first, that deleting a note you do not own is not allowed. If a rule would still be true with a different database and a different API, it belongs in the service.

The layout

myapi/
go.mod
Makefilerun, test, lint — so you stop retyping
cmd/
api/
main.gothe only file that knows how it all connects
internal/private to this project, enforced by the compiler
config/reads and checks the environment, once
database/opens the pool
httpx/shared JSON and error helpers
middleware/logging, recovery, auth — applies to every request
user/ONE feature, top to bottom
model.gothe type and its request shapes
repository.goSQL only
service.gothe rules
handler.goHTTP only
errors.gothis feature's error values
service_test.go
note/the next feature, same five files
migrations/numbered .sql files
.env.exampleevery key, no values. The real .env is gitignored.
One folder per feature. To remove a feature you delete its folder and its few lines of wiring in main.go.

The rule that holds it together

Dependencies point inward: handler → service → repository, and never the other way. The repository never imports net/http. The service ideally imports neither net/http nor database/sql.

This is what makes each layer testable on its own. The service can be tested with a fake repository and no database. The repository can be tested against a real database and no HTTP server. Neither test needs the other layer to exist.

the direction, as code
handler.go imports net/http + service
service.go imports the repository interface it declares itself
repository.go imports database/sql
// and never the reverse: no arrow points back up

model.go — the type and its input

internal/user/model.go
package user
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
PasswordHash string `json:"-"` // never leaves the server
CreatedAt time.Time `json:"created_at"`
}
// CreateRequest is what a client is allowed to send. It is NOT
// the User type, so nobody can post an "id" or a "password_hash".
type CreateRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
func (r CreateRequest) Validate() error {
if strings.TrimSpace(r.Name) == "" {
return fmt.Errorf("%w: name is required", ErrValidation)
}
if !strings.Contains(r.Email, "@") {
return fmt.Errorf("%w: a valid email is required", ErrValidation)
}
return nil
}
A separate request type is not extra work for its own sake. It is what stops a client setting fields you never meant them to control.

repository.go — storage, and nothing else

internal/user/repository.go
type Repository struct{ db *sql.DB }
func NewRepository(db *sql.DB) *Repository { return &Repository{db: db} }
func (r *Repository) Create(ctx context.Context, u *User) error {
return r.db.QueryRowContext(ctx,
`INSERT INTO users (name, email, password_hash)
VALUES ($1, $2, $3) RETURNING id, created_at`,
u.Name, u.Email, u.PasswordHash,
).Scan(&u.ID, &u.CreatedAt)
}
func (r *Repository) Delete(ctx context.Context, id int64) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = $1`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}

service.go — the rules

internal/user/service.go
// Store is declared HERE, by the code that uses it — not next to the type
// that implements it. That way it is exactly as big as this service needs,
// and a test fake has two methods to write instead of fifteen.
type Store interface {
Create(ctx context.Context, u *User) error
GetByID(ctx context.Context, id int64) (*User, error)
}
type Service struct{ repo Store }
func NewService(r Store) *Service { return &Service{repo: r} }
func (s *Service) Create(ctx context.Context, req CreateRequest) (*User, error) {
if err := req.Validate(); err != nil {
return nil, err
}
u := &User{Name: req.Name, Email: strings.ToLower(req.Email)} // a rule
if err := s.repo.Create(ctx, u); err != nil {
return nil, err
}
return u, nil
}
Lowercasing the email is a business rule, so it lives here, not in the handler or the SQL.

handler.go — HTTP, and nothing else

internal/user/handler.go
type Handler struct{ svc *Service }
func (h *Handler) Routes(mux *http.ServeMux) {
mux.HandleFunc("POST /users", h.create)
mux.HandleFunc("GET /users", h.list)
mux.HandleFunc("GET /users/{id}", h.get)
mux.HandleFunc("PATCH /users/{id}", h.update)
mux.HandleFunc("DELETE /users/{id}", h.delete)
}
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
httpx.Error(w, 400, "invalid id")
return
}
u, err := h.svc.GetByID(r.Context(), id)
if err != nil {
httpx.FromError(w, err)
return
}
httpx.JSON(w, 200, u)
}
Three steps: parse, call, encode. If a handler grows past that, the extra part belongs in the service.

main.go — wire it by hand

Nothing here makes a business decision. It reads config, opens the database, builds each feature from the inside out, and starts the server. You do not need a dependency injection framework; you need one function that builds objects in order.

cmd/api/main.go
func run() error {
cfg, err := config.Load()
if err != nil {
return err
}
db, err := database.Open(cfg.DatabaseURL)
if err != nil {
return err
}
defer db.Close()
userRepo := user.NewRepository(db) // inside
userSvc := user.NewService(userRepo) // middle
userH := user.NewHandler(userSvc) // outside
noteRepo := note.NewRepository(db)
noteSvc := note.NewService(noteRepo)
noteH := note.NewHandler(noteSvc)
mux := http.NewServeMux()
userH.Routes(mux)
noteH.Routes(mux)
handler := middleware.Chain(mux, middleware.Recover, middleware.RequestID, middleware.Logger)
return serve(cfg.Port, handler)
}
Adding a feature is one new folder and four lines here. Removing one is deleting both.

The habits that keep it clean

  • No global state. No package-level var db *sql.DB. Dependencies are struct fields, passed in. Globals are how a codebase becomes untestable.
  • Avoid `init()` for setup. It runs before main, in an order that is hard to see from the code, and it cannot return an error.
  • Config is read once, in main. Never call os.Getenv deep inside a handler.
  • No `utils` or `common` package. It becomes a dumping ground and causes import cycles.
  • No stuttering names. user.Service, not user.UserService. The package name is already part of every call.
Note: This is the layout the rest of the track uses. Everything from here (auth, uploads, tests, deployment) is added into these folders, so it is worth building once now.
The short version
  • One folder per feature, with model, repository, service and handler inside it.
  • A repository hides how data is stored. A service holds the rules. A handler only speaks HTTP.
  • Dependencies point inward: handler → service → repository, never the reverse.
  • Wire it by hand in run() error. No framework, no globals, no init().
Remember: The folders are not decoration. They are what stops your HTTP code and your SQL code from knowing about each other.
Build this before moving on
Build full CRUD for users in this exact layout, against Postgres, wired in main and checked with curl. This is the skeleton the whole second half of the track builds on, so take the time to get it right.
Then try these in your own editor
  1. 1Try importing net/http inside your repository package. Nothing stops you — which is why the rule has to be a habit.
  2. 2Move a package under internal/ and try to import it from a second module. Read the compiler error.
  3. 3Rename UserService to Service and fix every call site. Notice how much better user.Service reads.
  4. 4Add a second domain package alongside user/ and wire it in run() — no new frameworks, just more lines.
Check yourselfTake the Practice projects test