This episode discusses how to organize a Go project that uses chi so it can grow: separating handlers, services, and repositories, arranging the internal layout, applying dependency injection, and defining the router as a function that returns an http.Handler for easy testing.

All routes in a single main.go feel comfortable when your application is ten lines. When the application reaches thousands of lines with several resources, structure becomes the deciding factor for a project's survival. Episode 8 answers that with battle-tested patterns: handlers, services, repositories, combined with the internal layout and dependency injection.
There is no single mandatory recipe — what exists is principles: separation of responsibilities, clear dependency direction, and ease of testing. chi forces no structure at all; it gives you full freedom to build one.
Data flows one way: handler toward service toward repository. A repository never knows about HTTP; a handler never writes SQL.
type UserService interface {
GetByID(ctx context.Context, id int) (User, error)
}
type UserRepo interface {
FindByID(ctx context.Context, id int) (User, error)
}UserService and UserRepo are defined as interfaces on the caller's side — this makes mocking easy during testing, as we'll see in episode 17.
A layout that's proven to work well:
chi-service/
├── cmd/server/main.go
├── internal/
│ ├── handler/
│ ├── service/
│ └── repository/
└── go.modcmd/server/main.go only contains wiring: creating the router, injecting dependencies, running the server. All the logic lives in internal/, which can't be imported from outside the module.
Go's internal package cannot be imported by other modules — this is a compile-time restriction that protects your internal API. This layout separates the public interface (router, entrypoint) from the implementation.
Don't let handlers create their own dependencies. Inject them through a struct:
type UserHandler struct {
svc service.UserService
}
func (h *UserHandler) GetByID(w http.ResponseWriter, req *http.Request) {
id, _ := strconv.Atoi(chi.URLParam(req, "id"))
user, err := h.svc.GetByID(req.Context(), id)
if err != nil {
http.Error(w, "user tidak ditemukan", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, user)
}h.svc.GetByID(req.Context(), id) calls the injected service. The handler doesn't need to know where the service comes from — a real database in production, a mock in tests.
An alternative without structs is the closure:
func makeGetUser(svc service.UserService) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
id, _ := strconv.Atoi(chi.URLParam(req, "id"))
user, err := svc.GetByID(req.Context(), id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, user)
}
}makeGetUser(svc) returns a handler that closes over access to svc. This is the most concise pattern for simple resources.
A pattern used by many production projects:
type Deps struct {
Users *handler.UserHandler
}
func Routes(deps Deps) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/users", deps.Users.List)
r.Post("/users", deps.Users.Create)
r.Get("/users/{id}", deps.Users.GetByID)
return r
}Routes(deps Deps) http.Handler builds the whole router from the given dependencies. main.go just assembles Deps and hands the result to http.ListenAndServe.
Because Routes takes Deps, you can build a Deps with mock services and test the whole router:
deps := Deps{Users: &handler.UserHandler{
svc: mockUserService{},
}}
srv := httptest.NewServer(Routes(deps))
defer srv.Close()httptest.NewServer(Routes(deps)) runs the real router with fake dependencies — this is why this pattern became the standard for large-scale chi projects.
main, not inside handlers.Good structure keeps small changes from rippling through the whole application.
Key takeaways:
internal/ layout protects internal code from outside imports.Routes(deps) http.Handler for full testability.In the next episode 9 we connect the application to a database: database and ORM integration — pgx and database/sql, connection pooling, migrations, the repository pattern, and implementing CRUD inside handlers.