Learn Chi - Project Structure & Clean Architecture
Series/Learn Chi/Episode 8
Episode 8 of 23

Learn Chi - Project Structure & Clean Architecture

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.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

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.

Separating Handlers, Services, and Repositories

Three Layers of Responsibility

  • Handler: receives the request, reads input, calls the service, sends the response. Speaks the language of HTTP.
  • Service: carries business logic, validates domain rules, coordinates several repositories.
  • Repository: communicates with the database, abstracts queries, returns structured data.

Data flows one way: handler toward service toward repository. A repository never knows about HTTP; a handler never writes SQL.

Contracts between layers
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.

The internal Layout

A layout that's proven to work well:

Internal project layout
chi-service/
├── cmd/server/main.go
├── internal/
   ├── handler/
   ├── service/
   └── repository/
└── go.mod

cmd/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.

Why internal

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.

Dependency Injection

Handler Structs

Don't let handlers create their own dependencies. Inject them through a struct:

Handler with dependencies
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.

Injection via Closure

An alternative without structs is the closure:

Injection via 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.

Router as a Function

Takes Dependencies, Returns a Handler

A pattern used by many production projects:

Router as a function
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.

The Resulting Testability

Because Routes takes Deps, you can build a Deps with mock services and test the whole router:

Test router with mocks
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.

Best Practices to Hold On To

  • Thin router: the router only maps URLs to handlers, it contains no business logic.
  • Dependencies out: dependencies are built in main, not inside handlers.
  • Interfaces on the caller side: the caller defines what it needs.
  • One file, one role: avoid giant files that mix all layers together.

Good structure keeps small changes from rippling through the whole application.

Conclusion

Key takeaways:

  • Separate handlers, services, and repositories as three layers of responsibility.
  • The internal/ layout protects internal code from outside imports.
  • Dependency injection via structs or closures makes handlers easy to test.
  • Define Routes(deps) http.Handler for full testability.
  • The router only maps URLs; business logic lives in the service.
  • Interfaces are defined on the caller side so they're easy to mock.

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.

Learn Chi - Project Structure & Clean Architecture | Learn Chi