Skip to main content

Package Design — Architecture and Structural Principles

Good package design is the cornerstone of maintainable and extensible Go code. This section covers patterns and principles accumulated by the Go community.

internal Package​

Packages inside an internal directory can only be imported within the same module.

myproject/
├── go.mod
├── main.go
├── api/
│ └── handler.go # import "myproject/internal/auth" ✅
├── internal/
│ ├── auth/
│ │ └── auth.go # Cannot be imported externally
│ ├── db/
│ │ └── db.go
│ └── config/
│ └── config.go
└── pkg/
└── validator/
└── validator.go # Can be imported externally
// internal/auth/auth.go
package auth

import (
"errors"
"time"
)

// Token — public within the module but protected from external access
type Token struct {
UserID string
ExpiresAt time.Time
}

var ErrExpiredToken = errors.New("token has expired")

func Validate(token string) (*Token, error) {
if token == "" {
return nil, ErrExpiredToken
}
return &Token{UserID: "user-123", ExpiresAt: time.Now().Add(1 * time.Hour)}, nil
}
// Attempting to import from an external module causes a compile error:
// import "myproject/internal/auth"
// → use of internal package myproject/internal/auth not allowed

Preventing Circular Dependencies​

Go does not allow circular dependencies.

❌ Circular dependency:
package user → imports package order
package order → imports package user ← Compile error!

✅ Solution 1: Extract interface
package user → imports package store (interface)
package order → imports package store (interface)
package userstore → implements store interface
package orderstore → implements store interface

✅ Solution 2: Common types package
package types (contains only shared types)
package user → imports types
package order → imports types
// ✅ Resolve circular dependency with interfaces

// pkg/store/store.go — defines only interfaces
package store

type UserStore interface {
GetUser(id string) (*User, error)
SaveUser(user *User) error
}

type User struct {
ID string
Name string
}

type OrderStore interface {
GetOrder(id string) (*Order, error)
SaveOrder(order *Order) error
}

type Order struct {
ID string
UserID string
Amount float64
}
// internal/service/user.go — depends on store interface
package service

import "myproject/pkg/store"

type UserService struct {
userStore store.UserStore
orderStore store.OrderStore
}

func NewUserService(us store.UserStore, os store.OrderStore) *UserService {
return &UserService{userStore: us, orderStore: os}
}

func (s *UserService) GetUserWithOrders(userID string) (*store.User, []*store.Order, error) {
user, err := s.userStore.GetUser(userID)
if err != nil {
return nil, nil, err
}
return user, nil, nil
}

Layered Architecture​

A layered architecture commonly used in real-world projects.

┌─────────────────────────────────────┐
│ Handler (API Layer) │ ← HTTP request/response
├─────────────────────────────────────┤
│ Service (Business Logic) │ ← Business rules
├─────────────────────────────────────┤
│ Repository (Data Access) │ ← DB/external API access
├─────────────────────────────────────┤
│ Domain (Models) │ ← Core domain types
└─────────────────────────────────────┘
// domain/user.go
package domain

import "time"

type User struct {
ID string
Email string
Name string
CreatedAt time.Time
}

type UserRepository interface {
FindByID(id string) (*User, error)
FindByEmail(email string) (*User, error)
Save(user *User) error
Delete(id string) error
}
// repository/user_repo.go
package repository

import (
"database/sql"
"myproject/domain"
)

type userRepository struct {
db *sql.DB
}

func NewUserRepository(db *sql.DB) domain.UserRepository {
return &userRepository{db: db}
}

func (r *userRepository) FindByID(id string) (*domain.User, error) {
var u domain.User
err := r.db.QueryRow(
"SELECT id, email, name, created_at FROM users WHERE id = $1", id,
).Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt)
if err != nil {
return nil, err
}
return &u, nil
}

func (r *userRepository) FindByEmail(email string) (*domain.User, error) {
return nil, nil
}

func (r *userRepository) Save(user *domain.User) error {
return nil
}

func (r *userRepository) Delete(id string) error {
return nil
}
// service/user_service.go
package service

import (
"errors"
"myproject/domain"
)

type UserService struct {
repo domain.UserRepository
}

func NewUserService(repo domain.UserRepository) *UserService {
return &UserService{repo: repo}
}

func (s *UserService) GetUser(id string) (*domain.User, error) {
if id == "" {
return nil, errors.New("user ID is required")
}
return s.repo.FindByID(id)
}

func (s *UserService) RegisterUser(email, name string) (*domain.User, error) {
existing, _ := s.repo.FindByEmail(email)
if existing != nil {
return nil, errors.New("email already registered")
}

user := &domain.User{
ID: generateID(),
Email: email,
Name: name,
}
return user, s.repo.Save(user)
}

func generateID() string {
return "user-" + "generated-id" // Use UUID in real code
}
// api/handler.go
package api

import (
"encoding/json"
"net/http"
"myproject/service"
)

type UserHandler struct {
userService *service.UserService
}

func NewUserHandler(us *service.UserService) *UserHandler {
return &UserHandler{userService: us}
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id") // Go 1.22+
user, err := h.userService.GetUser(userID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}

Package Design Principles​

1. Single Responsibility Principle​

// ❌ Too many responsibilities
package utils

func ParseJSON(data []byte) (interface{}, error) { ... }
func SendEmail(to, subject, body string) error { ... }
func HashPassword(pw string) string { ... }
func ValidatePhone(phone string) bool { ... }

// ✅ Split by responsibility
package json // JSON parsing
package email // Email sending
package crypto // Encryption
package validator // Validation

2. Name Packages by Role​

// ❌ Verb-form package names (Go convention violation)
package convertstring
package handleusers

// ✅ Noun-form, short and clear
package strconv // string conversion
package user // user domain

3. Small Interfaces​

// ❌ Interface too large
type UserManager interface {
Create(user User) error
Read(id string) (User, error)
Update(user User) error
Delete(id string) error
List() ([]User, error)
Authenticate(email, password string) (Token, error)
ResetPassword(email string) error
// ... continues
}

// ✅ Just what's needed
type UserReader interface {
FindByID(id string) (*User, error)
}

type UserWriter interface {
Save(user *User) error
Delete(id string) error
}

// Compose when needed
type UserRepository interface {
UserReader
UserWriter
}

Directory Structure — Standard Layout​

A project structure widely used in the Go community.

myproject/
├── cmd/ # Executable binaries
│ └── server/
│ └── main.go
├── internal/ # Internal packages
│ ├── config/
│ ├── middleware/
│ └── repository/
├── pkg/ # Publicly exported packages
│ └── validator/
├── api/ # API definitions (OpenAPI, Proto, etc.)
├── web/ # Frontend assets
├── scripts/ # Build/deploy scripts
├── docs/ # Documentation
├── go.mod
├── go.sum
└── Makefile

Key Takeaways​

  • internal package: Protects API boundary by blocking external imports
  • Preventing circular deps: Extract interfaces or use a shared types package
  • Layered architecture: Handler → Service → Repository → Domain
  • Small interfaces: Include only necessary methods, extend through composition
  • Package names are nouns: Short and clear, avoid plurals and common prefixes