Building Scalable REST APIs with Go and Clean Architecture
As a backend developer working with Go at HMDTIF FILKOM UB, I've learned that building scalable REST APIs isn't just about writing code that works—it's about writing code that lasts. In this article, I'll share my experience implementing Clean Architecture in a real-world Go project.
Why Clean Architecture?
When I first started building the HMDTIF backend system, I faced a common challenge: how to structure the codebase so it remains maintainable as features grow. Clean Architecture, popularized by Robert C. Martin, provides a solution by separating concerns into distinct layers.
The key benefits I've experienced:
- Easier testing and mocking
- Independence from frameworks and databases
- Clear separation of business logic
- Better team collaboration
Project Structure
Here's the structure I use for Go projects:
project/
├── cmd/
│ └── main.go
├── internal/
│ ├── domain/ # Business entities
│ ├── usecase/ # Business logic
│ ├── repository/ # Data access
│ └── handler/ # HTTP handlers
├── pkg/
│ ├── middleware/
│ └── response/
└── config/
The Domain Layer
The domain layer contains your core business entities. These are pure Go structs with no external dependencies:
type News struct {
ID int `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Content string `json:"content"`
AuthorID int `json:"author_id"`
IsFeatured bool `json:"is_featured"`
PublishedAt time.Time `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
The Repository Layer
Repositories handle data persistence. I use interfaces to keep the code testable:
type NewsRepository interface {
Create(news *News) error
GetByID(id int) (*News, error)
GetBySlug(slug string) (*News, error)
Update(news *News) error
Delete(id int) error
List(filter NewsFilter) ([]*News, error)
}
The Use Case Layer
This is where business logic lives. Use cases orchestrate the flow of data:
type NewsUseCase struct {
repo NewsRepository
}
func (uc *NewsUseCase) CreateNews(input CreateNewsInput) (*News, error) {
// Validate input
if err := input.Validate(); err != nil {
return nil, err
}
// Generate slug
slug := generateSlug(input.Title)
// Create news entity
news := &News{
Title: input.Title,
Slug: slug,
Content: input.Content,
AuthorID: input.AuthorID,
}
// Save to database
if err := uc.repo.Create(news); err != nil {
return nil, err
}
return news, nil
}
The Handler Layer
Handlers process HTTP requests and responses:
func (h *NewsHandler) CreateNews(c *fiber.Ctx) error {
var input CreateNewsInput
if err := c.BodyParser(&input); err != nil {
return c.Status(400).JSON(ErrorResponse{
Message: "Invalid request body",
})
}
news, err := h.useCase.CreateNews(input)
if err != nil {
return c.Status(500).JSON(ErrorResponse{
Message: err.Error(),
})
}
return c.Status(201).JSON(news)
}
Key Lessons Learned
After implementing 8+ CRUD endpoints for the HMDTIF system, here are my key takeaways:
1. Start with interfaces Define your repository and use case interfaces first. This makes testing much easier.
2. Keep domain entities pure Don't let database concerns leak into your domain models. Use separate structs for database operations if needed.
3. Use middleware wisely Authentication, logging, and error handling are perfect candidates for middleware.
4. Validate early Validate input at the handler level before passing it to use cases.
5. Handle errors consistently Create a standard error response format and use it throughout your API.
Performance Considerations
In production, I've found these optimizations crucial:
- Database indexing: Index foreign keys and frequently queried columns
- Connection pooling: Configure proper pool sizes for your database
- Caching: Use Redis for frequently accessed data
- Pagination: Always paginate list endpoints
- Rate limiting: Protect your API from abuse
Testing Strategy
Clean Architecture makes testing straightforward:
func TestCreateNews(t *testing.T) {
// Mock repository
mockRepo := &MockNewsRepository{}
useCase := NewNewsUseCase(mockRepo)
// Test case
input := CreateNewsInput{
Title: "Test News",
Content: "Test content",
AuthorID: 1,
}
news, err := useCase.CreateNews(input)
assert.NoError(t, err)
assert.NotNil(t, news)
}
Conclusion
Clean Architecture has significantly improved the maintainability of my Go projects. While it requires more initial setup, the long-term benefits are worth it—especially when working in teams or maintaining code over time.
The key is to start simple and gradually adopt these patterns as your project grows. Don't over-engineer from day one, but keep these principles in mind as you build.
Resources: