Practice 3 — CRUD, one folder per feature
The final layout, and the one the rest of this track is built on.
- 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
| File | Responsible for | Knows about |
|---|---|---|
model.go | The type itself and the shape of incoming requests. Field validation lives here. | nothing outside its own package |
repository.go | Getting data in and out of storage. SQL and nothing else. | database/sql |
service.go | The rules. What is allowed, what happens in what order, what counts as an error. | the repository (through an interface it defines itself) |
handler.go | HTTP 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
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.
handler.go imports net/http + serviceservice.go imports the repository interface it declares itselfrepository.go imports database/sql// and never the reverse: no arrow points back upmodel.go — the type and its input
package usertype 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}repository.go — storage, and nothing else
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
// 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}handler.go — HTTP, and nothing else
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)}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.
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)}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.Getenvdeep inside a handler. - No `utils` or `common` package. It becomes a dumping ground and causes import cycles.
- No stuttering names.
user.Service, notuser.UserService. The package name is already part of every call.
- 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, noinit().
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.- 1Try importing
net/httpinside your repository package. Nothing stops you — which is why the rule has to be a habit. - 2Move a package under
internal/and try to import it from a second module. Read the compiler error. - 3Rename
UserServicetoServiceand fix every call site. Notice how much betteruser.Servicereads. - 4Add a second domain package alongside
user/and wire it inrun()— no new frameworks, just more lines.