Go Standard Layered Structure and Flattened pkg Modules (A Personal Practice Note)

In Go (Golang) projects, how to organize directory structures is a frequently asked question.
Go does not enforce a strict convention, but the community has gradually formed some common practices.

This article is divided into two parts:

  1. First, introduce the Standard Layered Structure.
  2. Then, show my personal preferred improvement: the Flattened pkg Module Structure.

1. Standard Layered Structure

In many teams, the common directory organization looks like this:

myapp/
├── cmd/             # Application entry
│   └── myapp/
│       └── main.go
├── internal/        # Internal business logic
│   ├── service/     # Business logic layer
│   ├── repository/  # Data access layer (DB, cache, etc.)
│   └── config/      # Configuration management
├── pkg/             # Reusable libraries (public)
├── api/             # API definitions (OpenAPI/Protobuf)
├── migrations/      # Database migration files
├── go.mod
└── README.md

Directory explanation

  • cmd/
    Application entry. Each subdirectory corresponds to an executable program, with main.go kept simple.
  • internal/
    Internal logic, clearly layered: service/, repository/, config/.
  • pkg/
    Reusable libraries, in theory can be imported by external projects.
  • api/
    Stores interface definitions, useful for auto-generating clients or server code.
  • migrations/
    Database migration files, managed with tools.

The advantage of this structure is clarity and decoupling, making it easier for team members to understand and maintain, and convenient for testing.


2. Flattened pkg Module Structure

Although the standard layered approach is common, in real-world development one issue often arises:
Too many subdirectories, leading to duplicate package names and confusing imports.

That’s why I prefer a flattened approach: under pkg/<module>/, place service.go, repository.go, models.go, errors.go directly. Each module has only one package, keeping the directory clean.

Example structure

myapp/
├── cmd/
│   └── myapp/
│       └── main.go
├── internal/
│   └── config/
├── pkg/
│   ├── user/
│   │   ├── models.go
│   │   ├── errors.go
│   │   ├── repository.go
│   │   └── service.go
│   └── order/
│       ├── models.go
│       ├── errors.go
│       ├── repository.go
│       └── service.go
├── go.mod
└── README.md

Module example: user

models.go

package user

type User struct {
    ID   string
    Name string
}

errors.go

package user

import "errors"

var (
    ErrNotFound      = errors.New("user not found")
    ErrAlreadyExists = errors.New("user already exists")
    ErrInvalidInput  = errors.New("invalid input")
)

repository.go

package user

import "sync"

type Repository interface {
    GetByID(id string) (User, error)
    Create(u User) error
    Update(u User) error
    Delete(id string) error
}

type InMemoryRepo struct {
    mu sync.RWMutex
    m  map[string]User
}

func NewInMemoryRepo() *InMemoryRepo {
    return &InMemoryRepo{m: make(map[string]User)}
}

func (r *InMemoryRepo) GetByID(id string) (User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    u, ok := r.m[id]
    if !ok {
        return User{}, ErrNotFound
    }
    return u, nil
}

// Other methods (Create/Update/Delete) are similar

service.go

package user

import "strings"

type Service interface {
    GetByID(id string) (User, error)
    Create(u User) error
    Rename(id, newName string) error
}

type DefaultService struct {
    repo Repository
}

func NewDefaultService(r Repository) *DefaultService {
    return &DefaultService{repo: r}
}

func (s *DefaultService) GetByID(id string) (User, error) {
    return s.repo.GetByID(id)
}

func (s *DefaultService) Create(u User) error {
    if strings.TrimSpace(u.ID) == "" || strings.TrimSpace(u.Name) == "" {
        return ErrInvalidInput
    }
    return s.repo.Create(u)
}

func (s *DefaultService) Rename(id, newName string) error {
    if strings.TrimSpace(newName) == "" {
        return ErrInvalidInput
    }
    u, err := s.repo.GetByID(id)
    if err != nil {
        return err
    }
    u.Name = newName
    return s.repo.Update(u)
}

3. Application entry (main.go)

package main

import (
    "fmt"
    "example.com/myapp/pkg/user"
)

func main() {
    repo := user.NewInMemoryRepo()
    svc := user.NewDefaultService(repo)

    u := user.User{ID: "u_001", Name: "Alice"}
    if err := svc.Create(u); err != nil {
        panic(err)
    }
    got, err := svc.GetByID("u_001")
    if err != nil {
        panic(err)
    }
    fmt.Printf("hello, %s\n", got.Name)
}

Run result:

hello, Alice

4. Comparison and Conclusion

Standard Layered Structure

  • Pros: Clear layering, suitable for large projects, widely accepted in the community.
  • Cons: Too many subdirectories, risk of duplicate package names.

Flattened pkg Module Structure

  • Pros: Each module has only one package, imports are more intuitive; simpler structure.
  • Cons: When modules get very complex, you might still need finer-grained subdirectories.

My Choice

For small-to-medium projects, I prefer the flattened pkg module structure:

  • Each module in its own directory, responsibilities split by file.
  • Avoids duplicate package names.
  • Retains layered thinking, while staying simple.

As the project grows larger, some modules can later evolve into independent modules or separate repositories.