Session 10 Β· πŸ›οΈ Layers

Layers, Folders & Interfaces

Four responsibilities that always exist in a backend, whether or not you separate them. Separating them is what makes the booking rule testable without a database and replaceable without touching HTTP.

βœ“
10asplitting a fat handler
βœ“
10bthe folder tree
βœ“
10cinterfaces and coupling

10a

One handler, four jobs

Everything from Sessions 05–09 currently lives in one function. Walk it with Next β€” the four layers are already there, just not separated.

HTTPHandler / Controller β€” decode the request, call one service method, turn the result into a status code and a body. Nothing else. If it is nine lines, it is right.
RULEService / Use Case β€” the business rules. "Appointments cannot be booked in the past", "a cancellation within 24 hours still charges the fee". No HTTP types, no SQL.
SQLRepository β€” every statement, every GORM call, every driver error translated. Nothing above this layer imports gorm.
DATAModel / Entity β€” the shapes everyone shares. Plain structs; no methods that reach for a database or a request.

The test that settles any argument about where code goes: can you change the database without touching anything that mentions HTTP, and change the HTTP API without touching anything that mentions SQL? If yes, the layering is real.

10b

Interfaces point the dependency the other way

The service needs to save appointments. If it imports the repository package, it also imports GORM β€” so instead, the service declares what it needs and the repository satisfies it.

internal/service/appointment.go β€” the interface lives with its CONSUMER
package service

// The service declares what it needs. It does not know postgres exists.
type AppointmentRepository interface {
    Book(ctx context.Context, a *model.Appointment) error
    ByID(ctx context.Context, id uint) (*model.Appointment, error)
    DayList(ctx context.Context, doctor uint, day time.Time) ([]model.Appointment, error)
}

type Clock interface{ Now() time.Time }   // so "in the past" is testable

type AppointmentService struct {
    repo  AppointmentRepository
    clock Clock
}

func NewAppointmentService(r AppointmentRepository, c Clock) *AppointmentService {
    return &AppointmentService{repo: r, clock: c}
}

func (s *AppointmentService) Book(ctx context.Context, a *model.Appointment) error {
    if a.StartsAt.Before(s.clock.Now()) {
        return ErrPastSlot          // a business rule, not a database rule
    }
    return s.repo.Book(ctx, a)
}
βœ“Declare the interface in the package that uses it, not the one that implements it. This is the Go convention and it is what inverts the dependency β€” service imports nothing, postgres imports service's shape by satisfying it implicitly.
βœ“Keep interfaces small. Three methods the service actually calls, not thirty the repository happens to expose. A fake for a 3-method interface is ten lines; for a 30-method one nobody writes it.
β†’Go satisfies interfaces implicitly, so postgres.AppointmentRepo needs no implements declaration and no import of service at all β€” the compiler checks it at the wiring point.
β†’Injecting a Clock looks fussy until you test it. "Reject bookings in the past" is untestable against time.Now(); with a clock it is three lines and no sleeping.

10c

A folder tree you can defend

The structure is not a matter of taste once the rule is fixed: dependencies point inward, and nothing inward imports anything outward.

the layout β€” each folder has exactly one job
cmd/api/main.go              // the composition root β€” session 11

internal/
  model/                     // entities. imports nothing of ours.
    appointment.go
    patient.go

  service/                   // business rules + the interfaces it needs
    appointment.go           //   AppointmentRepository, AppointmentService
    errors.go                //   ErrPastSlot, ErrNotFound …

  repository/postgres/       // every SQL statement in the codebase
    db.go                    //   gorm.Open + pool settings  (session 05)
    appointment.go           //   Book, ByID, DayList        (session 09)
    scopes.go                //   Active, ForDoctor, OnDay   (session 07)

  transport/http/            // handlers, routing, middleware
    appointment.go
    errors.go                //   one error→status mapper    (session 11)

  config/                    // read the environment once     (session 11)
    config.go

migrations/                  // versioned .sql files          (session 06)
Package
May import
Must never import
model
stdlib only
service, repository, transport
service
model
gorm, net/http, transport
repository/postgres
model, gorm, pgconn
net/http, transport
transport/http
model, service, net/http
gorm, pgconn
cmd/api
everything β€” it is the wiring
business logic
βœ—Never create a utils or common package. It has no single job, so everything ends up in it, and within a year it imports half the codebase and creates cycles.
β†’internal/ is enforced by the compiler. Nothing outside this module can import these packages β€” free protection against your API becoming someone's dependency by accident.
βœ“Group by layer at this size; group by feature when it grows. Past roughly fifteen entities, internal/appointment/{service,repo,http}.go starts beating four parallel folders β€” the same rule, different axis.
β†’The import rules above are checkable. go list -deps or a linter such as depguard will fail the build if service ever imports gorm β€” worth doing on day one.
TASK

Split the fat handler for real

Take the single-function booking handler from the animation. (a) Split it into the four layers, creating the folders above. (b) Define AppointmentRepository in the service package with only the methods the service calls, and make your GORM repository satisfy it without importing the service package. (c) Write one test for "cannot book in the past" that uses a fake repository and a fixed clock β€” no database, no HTTP, and it must run in under a millisecond.

Recap

Session 10 in four lines

1Four responsibilities always exist β€” handler, service, repository, model. Separating them is what makes each one replaceable.
2Declare interfaces in the package that consumes them, keep them to the methods actually called, and let Go satisfy them implicitly.
3Dependencies point inward: model imports nothing, service never imports gorm, transport never imports a driver.
4If you can swap the database without touching HTTP code, the layering is real β€” otherwise it is just folders.