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.
10a
Everything from Sessions 05β09 currently lives in one function. Walk it with Next β the four layers are already there, just not separated.
gorm.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
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.
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) }
service imports nothing, postgres imports service's shape by satisfying it implicitly.postgres.AppointmentRepo needs no implements declaration and no import of service at all β the compiler checks it at the wiring point.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
The structure is not a matter of taste once the rule is fixed: dependencies point inward, and nothing inward imports anything outward.
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)
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.internal/appointment/{service,repo,http}.go starts beating four parallel folders β the same rule, different axis.go list -deps or a linter such as depguard will fail the build if service ever imports gorm β worth doing on day one.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
model imports nothing, service never imports gorm, transport never imports a driver.