This episode structures a Gin project with clean architecture: separating handlers, services, and repositories; using the internal folder; one package per domain; simple dependency injection; and separating the router setup so it's easy to test and scale.

During episodes 3 to 7, all the code was written in a single main.go file. That's fine for learning, but it doesn't hold up as the project grows. This episode 8 teaches you how to structure a Gin project with clean architecture — dividing responsibilities into clear layers so it's easy to test, easy to swap out, and easy for many people to work on.
The governing principle is simple: handlers shouldn't know database details, services shouldn't know HTTP details, and repositories shouldn't know how requests arrive. Each layer depends only on contracts (interfaces), not implementations.
Putting all the logic in one file causes several problems: it's hard to test because handlers are tied directly to the database connection, hard to change because schema changes ripple through every handler, and easy to conflict when working in a team. That's why we separate roles:
With separation, each layer can be tested independently. Handlers are tested with httptest, services with fake repositories (mocks), and repositories directly against the database. You'll practice all of this in episode 17.
An example standard layout for a Gin project:
belajar-gin/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── config/
│ │ └── config.go
│ ├── router/
│ │ └── router.go
│ ├── user/
│ │ ├── handler.go
│ │ ├── service.go
│ │ └── repository.go
│ └── middleware/
│ └── auth.go
└── go.modThe internal/ folder is a special Go feature: packages inside it cannot be imported from outside the module. This limits user access and keeps internal APIs private. The cmd/server folder holds the entry point, while one folder per domain (for example user, order, payment) holds that domain's handler, service, and repository.
cmd/server/main.go only wires up all the dependencies:
func main() {
cfg := config.Load()
db := config.ConnectDB(cfg)
repo := user.NewRepository(db)
svc := user.NewService(repo)
handler := user.NewHandler(svc)
r := router.New(handler)
r.Run(":8080")
}Inside internal/user, each file has its own responsibility. The repository defines the data access contract:
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
Create(ctx context.Context, u *User) error
}
type userRepo struct {
db *sql.DB
}
func NewRepository(db *sql.DB) UserRepository {
return &userRepo{db: db}
}The function NewRepository(db *sql.DB) UserRepository returns an interface while hiding the concrete struct. This makes swapping implementations and creating mocks in tests easy.
The service uses the repository; the handler uses the service:
type UserService interface {
GetUser(ctx context.Context, id int64) (*User, error)
}
type userService struct {
repo UserRepository
}
func (s *userService) GetUser(ctx context.Context, id int64) (*User, error) {
return s.repo.FindByID(ctx, id)
}type UserHandler struct {
svc UserService
}
func NewHandler(svc UserService) *UserHandler {
return &UserHandler{svc: svc}
}
func (h *UserHandler) GetUser(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(400, gin.H{"error": "id tidak valid"})
return
}
user, err := h.svc.GetUser(c.Request.Context(), id)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, user)
}The GetUser handler doesn't know how data is stored — it just calls h.svc.GetUser(c.Request.Context(), id). HTTP details and database details are isolated in their own layers.
Dependencies are injected through constructors rather than imported directly inside handlers. The benefit: a handler can receive different implementations, including mocks during testing. This is called constructor injection, the simplest pattern for Go. For larger projects, you could use containers like wire, fx, or dig, but for most cases manual constructor injection is sufficient and the easiest to trace.
The router is also separated into its own file so it can be recreated quickly during tests:
func New(userHandler *user.UserHandler) *gin.Engine {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
api := r.Group("/api")
api.GET("/users/:id", userHandler.GetUser)
return r
}The function router.New(userHandler) returns a ready-to-use engine. With this pattern, the tests in episode 17 simply call router.New(mockHandler) and a fresh engine is born for each test without global side effects.
Key takeaways:
internal/ folder keeps packages private from outside imports.router.New so the engine is easy to recreate during tests.main.go is only wiring, not a place for logic.In the next episode, episode 9, we'll dissect database & ORM integration — connecting to PostgreSQL with pgxpool, connection pooling, database migrations, and implementing the repository pattern with full CRUD in Gin handlers.