Go 标准分层结构与扁平化 pkg 模块(个人偏好的实践分享)

在 Go(Golang)项目中,如何组织目录结构是一个高频问题。
Go 没有强制规范,但社区里逐渐沉淀出了一些常见做法。

这篇文章介绍我个人更倾向的一种改良:扁平化的 pkg 模块结构


扁平化的 pkg 模块结构

虽然标准分层很常见,但在实际开发中会遇到一个问题:
子目录太多,容易出现同名 package,import 时让人混淆。

于是我更偏好一种改良方案:在 pkg/<模块名>/ 下,直接放置 service.gorepository.gomodels.goerrors.go。这样每个模块只有一个 package,目录更干净。

示例结构

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

模块示例: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
}

// 省略 Create/Update/Delete 的实现,逻辑类似

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)
}

三、应用入口(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)
}

运行结果:

hello, Alice

对比与总结

标准分层结构

  • 优点:层次分明、适合大型项目、社区普遍接受。
  • 缺点:子目录较多,容易出现 package 名冲突。

扁平化 pkg 模块

  • 优点:每个模块只有一个 package,import 更直观;结构简洁。
  • 缺点:当模块特别复杂时,可能需要更细的子目录拆分。

我的选择

我更倾向在中小规模项目里,使用 扁平化 pkg 模块结构

  • 每个模块独立目录,内部职责用文件区分;
  • 避免同名 package 的困扰;
  • 保留分层思想,同时保证简洁。

当项目进一步扩大时,再考虑是否演进为 独立模块(module)独立仓库