English | 한국어
Every time I built a backend server with Go, I'd start thinking "let me structure this more elegantly," only to end up rewriting everything halfway through the project. This is the structure I finally settled on.
Created to avoid refactoring hell, and for future me to reference when asking "how did I do that again?"
A layered architecture Go backend template.
- Architecture
- Layer Responsibilities
- Key Implementation Details
- Getting Started
- Testing
- Adding a New Domain
- Scaling Considerations
This template follows a layered architecture pattern:
Route → Middleware → Handler → Service → Repository
go-backend-template/
├── cmd/
│ └── server/ # Application entry point
│ ├── main.go
│ └── config.go
├── internal/
│ ├── app/
│ │ └── server/ # HTTP server implementation
│ │ ├── server.go
│ │ ├── handler/ # HTTP handlers (controllers)
│ │ │ ├── base.go # Common error handling
│ │ │ ├── context.go # Context helpers
│ │ │ └── user/ # User domain handlers
│ │ │ ├── handler.go
│ │ │ └── dto.go # Request/Response DTOs
│ │ ├── middleware/ # HTTP middlewares
│ │ │ └── auth/
│ │ │ └── auth.go
│ │ ├── routes/ # Route definitions
│ │ │ ├── routes.go
│ │ │ └── user.go
│ │ └── service/ # Business logic layer
│ │ └── user/
│ │ ├── service.go
│ │ ├── input.go # Service input types
│ │ └── dependencies.go # Dependency interfaces
│ └── pkg/ # Shared internal packages
│ ├── auth/ # Authentication utilities
│ │ ├── jwt.go
│ │ └── password.go
│ ├── domain/ # Domain errors
│ │ └── errors.go
│ ├── entity/ # Domain entities
│ │ └── user.go
│ └── repository/ # Data access layer
│ ├── errors.go # Common repository errors
│ └── postgres/
│ ├── repository.go
│ ├── schema.go # Table schemas
│ └── user.go
├── build/
│ └── Dockerfile
├── deployments/
│ ├── docker-compose.yml
│ └── .env.example
├── go.mod
├── go.sum
├── Makefile
└── README.md
- Parse HTTP requests (path params, query, body)
- Validate request format
- Convert request DTO to service input
- Call service methods
- Convert service output to response DTO
- Handle errors and send HTTP responses
- Implement business logic
- Orchestrate repository calls
- Return domain errors
- No HTTP-related code
- Database access
- SQL queries
- Return database errors
- Domain entities
- Domain errors with HTTP status mapping
Separate Handler DTOs from Service Inputs. Handler focuses on HTTP request/response, Service focuses on business logic.
// handler/user/dto.go - for HTTP requests
type CreateUserRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
}
// service/user/input.go - for business logic
type CreateUserInput struct {
Email string
Username string
Password string
}Domain errors include HTTP status code mapping. Enables consistent error handling in Handler.
// domain/errors.go
type DomainError interface {
error
HTTPStatus() int
}
type UserNotFoundError struct {
Id int
}
func (e UserNotFoundError) Error() string {
return fmt.Sprintf("user not found with id: %d", e.Id)
}
func (e UserNotFoundError) HTTPStatus() int {
return http.StatusNotFound
}Service depends on interfaces, not concrete implementations. Facilitates mocking in tests.
// service/user/dependencies.go
type IUserRepository interface {
GetUserById(id int) (*entity.User, error)
InsertUser(user *entity.User) (int, error)
// ...
}
type IPasswordHasher interface {
Hash(password string) (string, error)
Compare(hashedPassword, password string) error
}Common error handling logic in BaseHandler. Domain-specific handlers embed and reuse.
// handler/base.go
type BaseHandler struct{}
func (b *BaseHandler) HandleDomainError(c *gin.Context, err error) {
if domainErr, ok := err.(domain.DomainError); ok {
c.AbortWithStatusJSON(domainErr.HTTPStatus(), gin.H{"message": domainErr.Error()})
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
}
// handler/user/handler.go
type UserHandler struct {
handler.BaseHandler // embedding
userService *user.Service
}- Go 1.21+
- PostgreSQL 16+
- Docker & Docker Compose (optional)
# Build binary
make build
# Build Docker image
make docker-build# Run all tests
make test
# Run tests with coverage
make test-coverage
# Run specific package tests
go test -v ./internal/app/server/service/user/...
go test -v ./internal/app/server/handler/user/...
go test -v ./internal/pkg/auth/...
go test -v ./internal/pkg/repository/postgres/...
# Run tests with race detection
go test -race ./...- Mock repository interface
- Mock password hasher
- Test business logic in isolation
- Verify domain error returns
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) GetUserById(id int) (*entity.User, error) {
args := m.Called(id)
return args.Get(0).(*entity.User), args.Error(1)
}- Use
httptestfor HTTP testing - Mock service layer
- Test request parsing and validation
- Test response formatting
router := gin.New()
router.POST("/users", handler.CreateUser)
req := httptest.NewRequest(http.MethodPost, "/users", body)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)- Unit tests for query building
- Integration tests with test database (skipped by default)
- Config validation tests
- JWT token generation and validation
- Password hashing and comparison
- Edge cases (expired tokens, wrong passwords)
- Create entity in
internal/pkg/entity/ - Add domain errors in
internal/pkg/domain/errors.go - Create repository methods in
internal/pkg/repository/postgres/ - Create service in
internal/app/server/service/<domain>/dependencies.go- Repository interfaceinput.go- Service input typesservice.go- Business logic
- Create handler in
internal/app/server/handler/<domain>/dto.go- Request/Response DTOshandler.go- HTTP handlers
- Add routes in
internal/app/server/routes/<domain>.go - Wire up in
server.go
Guide for when the project grows.
Consider fully separating by domain:
internal/
├── user/ # entire user domain
│ ├── handler/
│ ├── service/
│ ├── repository/
│ └── entity/
├── order/ # entire order domain
│ └── ...
As domains grow, separate errors by file:
internal/pkg/domain/
├── errors.go # Common errors (InternalServerError, ValidationError, etc.)
├── user_errors.go # User domain errors
├── order_errors.go # Order domain errors
└── product_errors.go # Product domain errors
Or with full domain separation:
internal/user/domain/errors.go
internal/order/domain/errors.go
If circular dependency occurs, review the design first.
Circular dependencies often indicate poor separation of responsibilities, or logic that should be coordinated at a higher layer is placed in services. Interface separation is a technical workaround, not a fundamental solution.
Design review example:
// Before: UserService directly checks orders before deletion
type UserService struct {
orderService *OrderService // circular dependency
}
// After: Coordinate at higher layer (Handler)
func (h *Handler) DeleteUser(userId int) error {
if hasOrders, _ := h.orderService.HasActiveOrders(userId); hasOrders {
return errors.New("active orders exist")
}
return h.userService.Delete(userId)
}Fallback: Separate with interfaces
When design changes are difficult, define only needed functionality as interfaces:
type IUserGetter interface {
GetUserById(id int) (*entity.User, error)
}
type IOrderChecker interface {
HasActiveOrders(userId int) (bool, error)
}
type UserService struct {
orderChecker IOrderChecker // depends on interface
}
type OrderService struct {
userGetter IUserGetter // depends on interface
}Unit of Work pattern for wrapping multiple Repositories in a single transaction:
type UnitOfWork interface {
Begin() error
Commit() error
Rollback() error
Users() IUserRepository
Orders() IOrderRepository
}This template uses raw SQL. As queries get complex, consider:
| Tool | Characteristics |
|---|---|
| sqlx | database/sql extension. Struct mapping, Named Query |
| sqlc | SQL → Go code generation. Type-safe, compile-time verification |
| squirrel | SQL query builder. Fluent API, good for dynamic queries |
| goqu | Query builder. Multiple DB dialects, actively maintained |
| GORM | Full ORM. Migrations, relations, hooks |
| ent | By Facebook. Schema-based codegen, graph traversal |
| Bun | Lightweight ORM. Good PostgreSQL support |
Selection criteria:
- Simple CRUD → sqlx
- Dynamic query generation → squirrel, goqu
- Type safety priority → sqlc
- Complex relations/migrations → GORM, ent
| Situation | Pattern/Tool to Consider |
|---|---|
| Query performance issues | Caching layer (Redis) |
| Async processing needed | Event-driven architecture |
| API backward compatibility | API versioning (/api/v1/, /api/v2/) |
| Service scale explosion | Microservices decomposition |
| Environment-specific config | Per-environment config files |
| Distributed tracing needed | OpenTelemetry |