Building Scalable REST APIs with Go and Clean Architecture

API Design
Backend
Clean Architecture
Go
2026-01-01

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: