This episode builds the foundation for organized code: separating handlers, services, and repositories, the internal layout following Go conventions, simple dependency injection without a library, and modularizing the router and middleware setup so it's easy to test and scale.

A handler that piles all logic into one file is quick to write but becomes a curse as the application grows. Clean architecture separates concerns so the code can be tested, read, and changed without fear. This is the episode that turns your small project into a codebase that can survive.
Episode 8 builds the foundation for organized code: separating handlers, services, and repositories; the internal/ layout following Go conventions; simple dependency injection without a library; and modularizing the router and middleware setup.
The basic principle is simple: each layer has one responsibility, and the dependency direction flows one way only.
internal/
handler/
user_handler.go
service/
user_service.go
repository/
user_repository.go
model/
user.goThe internal/ directory is a Go language feature: packages inside it cannot be imported from outside your module. This is a compile-time safeguard that keeps internal layers private.
mkdir -p internal/handler internal/service internal/repository internal/modelAnother important convention: filenames point to the same package (user_handler.go contains package handler), and each layer may only depend on the layers below it — handler may call service, service may call repository, but never the other way around.
Go doesn't need a DI framework. Just define an interface and inject its implementation through a constructor:
type UserRepository interface {
FindByID(ctx context.Context, id int) (*model.User, error)
}
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
type UserHandler struct {
svc *UserService
}
func NewUserHandler(svc *UserService) *UserHandler {
return &UserHandler{svc: svc}
}With the NewUserService(repo) and NewUserHandler(svc) pattern, you inject dependencies top-down. For testing, simply provide a mock implementation of the interface.
Assembly happens in main — one clear place to see all the application's dependencies:
func main() {
e := echo.New()
repo := repository.NewUserRepository()
svc := service.NewUserService(repo)
handler := handler.NewUserHandler(svc)
handler.RegisterRoutes(e.Group("/api/v1"))
e.Logger.Fatal(e.Start(":8080"))
}The RegisterRoutes method takes a group and registers all the handler's routes. With this, main stays lean and each part can be tested separately.
Middleware setup should be grouped so it doesn't mix with route declarations. Create a dedicated function:
func setupMiddleware(e *echo.Echo) {
e.Use(middleware.RequestLoggerWithConfig(loggingConfig()))
e.Use(middleware.Recover())
e.Use(middleware.CORS())
}This separation means middleware policy can be changed in one place and tested as a single unit.
Each domain has its own route file, registered from RegisterRoutes. This is what a modular router setup looks like:
func (h *UserHandler) RegisterRoutes(g *echo.Group) {
g.GET("/users", h.List)
g.GET("/users/:id", h.Get)
g.POST("/users", h.Create)
}As the application grows, add new modules without touching other files — just call RegisterRoutes from main.
This architecture changes how you test. Handlers can be tested with httptest (episode 17), services tested purely without HTTP, and repositories tested with a test database. Each layer has a clear testing scope.
go vet ./...Run go vet ./... from the project root to make sure all packages under internal/ are correctly arranged.
Episode 8 builds the foundation for organized code: handler, service, and repository as three layers with one dependency direction; the internal/ layout as a compile-time safeguard; simple dependency injection through constructors; and modularizing middleware setup and per-module routes.
Key takeaways:
internal/ prevents packages from being imported outside the module.main is the single place where dependencies are assembled.RegisterRoutes.In episode 9 next, we'll discuss database & ORM integration — PostgreSQL and MySQL connections with pgx, database/sql, GORM, and sqlc, connection pooling, schema migrations, and a complete CRUD implementation in Echo handlers.