Go: Go Project Architecture

Last updated: 2026-08-26

Go doesn't have a project structure enforced by a إطار عمل—but that means you have to design it yourself. A well-thought-out architecture keeps your project clear when it's 1,000 lines long and still manageable when it reaches 100,000 lines.

When you take over a project where all the code is crammed into main.go, the first step in refactoring is to move the code into the correct directories.

1. You will learn



2. A True Story of a Backend Engineer

(1) Pain Point: 100,000 lines of code are all in main.go

Alice took over an e-commerce project:

"My predecessor put all the code in main.go: route registration, قاعدة بيانات operations, HTML templates, and business logic—all jumbled together in 30,000 lines. When I wanted to add a user حالة field, I had to make changes in five different places to get it right. It took me two weeks to add a feature, and three days just to find the code."

GO
// Bad code: everything piled into main.go
package main

var db *sql.DB

func main() {
    // Database connection
    // Route registration
    // HTML templates
    // User handler
    // Order handler
    // Product handler
    // All mixed together!
}

// To find "user" related code: Ctrl+F search for "user", scattered across 50 places

(2) Go's Solution: 4-Layer Architecture

TEXT 📖 Display only
myapp/
├── cmd/
│   └── server/
│       └── main.go         # Entry → dependency injection + start service
├── internal/
│   ├── handler/            # Layer 1: HTTP handlers (parse request / return response)
│   ├── service/            # Layer 2: Business logic (domain rules)
│   └── repository/         # Layer 3: Data access (database / external API)
├── pkg/
│   └── model/              # Layer 4: Domain models (data structures)
└── go.mod

(3) Returns: Chaos vs. Stratification

Scenario Chaotic Architecture 4-Layer Architecture
New fields 5 changes at unknown locations Changes only to model + handler
Switch Databases Update All Database Calls Update Only the Repository Layer
Unit Testing Cannot Perform Unit Testing Each Layer Can Be Tested Independently Using Mocks
Getting Started for Newcomers Browsing Through 30,000 Lines of main.go Understanding Responsibilities Just by Looking at Directory Names


3. Standard Project Layout

(1) A Detailed Explanation of the Directory Structure

TEXT 📖 Display only
myproject/
├── cmd/                    # Executable entry points
│   ├── server/             #   server binary
│   │   └── main.go
│   └── migrate/            #   database migration tool
│       └── main.go
├── internal/               # Private packages (cannot be imported externally)
│   ├── handler/            #   HTTP handlers
│   ├── service/            #   Business logic
│   └── repository/         #   Data access
├── pkg/                    # Exportable public packages
│   └── model/              #   Domain models
├── migrations/             # SQL migration files
├── config/                 # Configuration files
├── scripts/                # Helper scripts
├── go.mod
└── go.sum

(2) cmd/ / internal/ / pkg/ Responsibilities

Directory Uses Can Be Imported Externally
cmd/ Executable entry point (main function) N/A (it is the main package)
internal/ Private implementation ❌ External imports are prohibited by the Go compiler
pkg/ Public code exposed to the outside world
💡 Tip: The internal directory is a special directory for the Go compiler—no package outside the parent directory of internal can import it. This provides true encapsulation, which is more secure than "naming conventions" (such as _private).



4. The Clean Architecture 4-Layer Model

TEXT 📖 Display only
handler (HTTP) → service (business) → repository (data)
     ↓                ↓                ↓
   Request parsing   Domain rules     Database/API
   Response return   Transaction mgmt CRUD operations
    Param validation  Multi-step orchestration  Cache access

▶ Example: 4-layer implementation

GO 📖 Display only
// ---------- Layer 4: Model (domain models) ----------
// pkg/model/user.go
package model

type User struct {
    ID        int
    Name      سلسلة
    Email     سلسلة
    CreatedAt time.Time
}

type CreateUserRequest struct {
    Name  سلسلة
    Email سلسلة
}

// ---------- Layer 3: Repository (data access) ----------
// internal/repository/user.go
package repository

import (
    "قاعدة بيانات/sql"
    "myapp/pkg/model"
)

type UserRepository interface {
    FindByID(id int) (*model.User, خطأ)
    Create(req model.CreateUserRequest) (*model.User, خطأ)
    List() ([]*model.User, خطأ)
    Delete(id int) خطأ
}

type userRepository struct {
    db *sql.DB
}

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

func (r *userRepository) FindByID(id int) (*model.User, خطأ) {
    row := r.db.QueryRow("SELECT id, name, email, created_at FROM users WHERE id = ?", id)
    user := &model.User{}
    err := row.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    return user, err
}

func (r *userRepository) Create(req model.CreateUserRequest) (*model.User, خطأ) {
    result, err := r.db.Exec("INSERT INTO users (name, email) VALUES (?, ?)", req.Name, req.Email)
    if err != nil {
        return nil, err
    }
    id, _ := result.LastInsertId()
    return r.FindByID(int(id))
}

// ---------- Layer 2: Service (business logic) ----------
// internal/service/user.go
package service

import (
    "errors"
    "myapp/internal/repository"
    "myapp/pkg/model"
    "strings"
)

var (
    ErrUserNotFound    = errors.New("user not found")
    ErrInvalidName     = errors.New("name is required")
    ErrInvalidEmail    = errors.New("invalid email format")
)

type UserService struct {
    repo repository.UserRepository
}

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

func (s *UserService) Create(req model.CreateUserRequest) (*model.User, خطأ) {
    if strings.TrimSpace(req.Name) == "" {
        return nil, ErrInvalidName
    }
    if !strings.Contains(req.Email, "@") {
        return nil, ErrInvalidEmail
    }
    return s.repo.Create(req)
}

func (s *UserService) Get(id int) (*model.User, خطأ) {
    user, err := s.repo.FindByID(id)
    if err != nil {
        return nil, err
    }
    if user == nil {
        return nil, ErrUserNotFound
    }
    return user, nil
}

// ---------- Layer 1: Handler (HTTP handler) ----------
// internal/handler/user.go
package handler

import (
    "encoding/json"
    "errors"
    "net/http"
    "strconv"

    "myapp/internal/service"
    "myapp/pkg/model"
)

type UserHandler struct {
    svc *service.UserService
}

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

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req model.CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON")
        return
    }

    user, err := h.svc.Create(req)
    if err != nil {
        writeError(w, http.StatusBadRequest, err.Error())
        return
    }

    writeJSON(w, http.StatusCreated, user)
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.Atoi(r.PathValue("id"))

    user, err := h.svc.Get(id)
    if errors.Is(err, service.ErrUserNotFound) {
        writeError(w, http.StatusNotFound, err.Error())
        return
    }
    if err != nil {
        writeError(w, http.StatusInternalServerError, "internal خطأ")
        return
    }

    writeJSON(w, http.StatusOK, user)
}

// ---------- Utility functions ----------

func writeJSON(w http.ResponseWriter, حالة int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(حالة)
    json.NewEncoder(w).Encode(data)
}

func writeError(w http.ResponseWriter, حالة int, msg سلسلة) {
    writeJSON(w, حالة, map[سلسلة]سلسلة{"خطأ": msg})
}
131 logic lines (exceeds 40-line limit, display only)

(2) Direction of Dependency

TEXT 📖 Display only
Handler → Service → Repository (→ DB)
   |          |           |
   ↓          ↓           ↓
  Model     Model       Model
Layer Responsibilities Dependencies Testable
Handler HTTP Parsing/Response Service Mock Service
Service Business Rules/Orchestration Repository Mock Repository
Repository Data CRUD قاعدة بيانات/sql Mock DB / Integration Testing
Model Data Structure None
🔥 Common Mistake: Dependencies must flow from the outer layer to the inner layer. The Handler knows the Service, and the Service knows the Repository, but the Repository must not know the Service. Each layer depends only on the layer below it, and decoupling is achieved through interfaces (Go's implicit interfaces make this feel natural).



5. Dependency Injection

▶ Example: Constructor Injection

⚙️ Prerequisite: Run go get github.com/mattn/go-sqlite3 (requires CGO; alternatively use modernc.org/sqlite for a pure Go driver)

GO
// cmd/server/main.go
package main

import (
    "database/sql"
    "log"
    "net/http"

    "myapp/internal/handler"
    "myapp/internal/repository"
    "myapp/internal/service"
)

func main() {
    // ---- Dependency injection (assemble all layers) ----

    // 1. Database connection
    db, err := sql.Open("sqlite3", "./app.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // 2. Repository layer
    userRepo := repository.NewUserRepository(db)
    orderRepo := repository.NewOrderRepository(db)

    // 3. Service layer (depends on Repository)
    userSvc := service.NewUserService(userRepo)
    orderSvc := service.NewOrderService(orderRepo, userRepo)

    // 4. Handler layer (depends on Service)
    userHandler := handler.NewUserHandler(userSvc)
    orderHandler := handler.NewOrderHandler(orderSvc)

    // 5. Route registration
    mux := http.NewServeMux()
    userHandler.Register(mux, "/api/v1/users")
    orderHandler.Register(mux, "/api/v1/orders")

    log.Println("Service listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}
▶ Try it Yourself

(2) Comparison of Dependency Injection Approaches

Method Implementation Advantages Disadvantages
Constructor injection NewXxx(dep) Explicit, compile-time checking Long code due to dependencies
Manual implementation Assembly of the main function No third-party libraries required Cumbersome to maintain in large projects
Google Wire Code Generation Automatic Injection Learning Curve
💡 Tip: Manual dependency injection is sufficient for small projects. When your project has 20 or more services and 50 or more dependencies, consider using Google Wire (github.com/google/wire) to automatically generate dependency injection code.



6. Complete Example: E-commerce Project Framework

▶ Example: Full Implementation

GO 📖 Display only
// cmd/خادم/main.go
package main

import (
    "context"
    "قاعدة بيانات/sql"
    "encoding/json"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    _ "github.com/mattn/go-sqlite3"
)

// ---------- Model (pkg/model) ----------

type Product struct {
    ID    int     `json:"id"`
    Name  سلسلة  `json:"name"`
    Price float64 `json:"price"`
}

type Order struct {
    ID        int       `json:"id"`
    UserID    int       `json:"user_id"`
    ProductID int       `json:"product_id"`
    Quantity  int       `json:"quantity"`
    Total     float64   `json:"total"`
    Status    سلسلة    `json:"حالة"`
    CreatedAt time.Time `json:"created_at"`
}

// ---------- Repository (internal/repository) ----------

type ProductRepository struct {
    db *sql.DB
}

func NewProductRepository(db *sql.DB) *ProductRepository {
    return &ProductRepository{db: db}
}

func (r *ProductRepository) FindByID(id int) (*Product, خطأ) {
    p := &Product{}
    err := r.db.QueryRow("SELECT id, name, price FROM products WHERE id = ?", id).
        Scan(&p.ID, &p.Name, &p.Price)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    return p, err
}

func (r *ProductRepository) List() ([]*Product, خطأ) {
    rows, err := r.db.Query("SELECT id, name, price FROM products")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var products []*Product
    for rows.Next() {
        p := &Product{}
        if err := rows.Scan(&p.ID, &p.Name, &p.Price); err != nil {
            return nil, err
        }
        products = append(products, p)
    }
    return products, rows.Err()
}

type OrderRepository struct {
    db *sql.DB
}

func NewOrderRepository(db *sql.DB) *OrderRepository {
    return &OrderRepository{db: db}
}

func (r *OrderRepository) Create(order *Order) خطأ {
    result, err := r.db.Exec(
        "INSERT INTO orders (user_id, product_id, quantity, total, حالة) VALUES (?, ?, ?, ?, ?)",
        order.UserID, order.ProductID, order.Quantity, order.Total, order.Status,
    )
    if err != nil {
        return err
    }
    id, _ := result.LastInsertId()
    order.ID = int(id)
    return nil
}

func (r *OrderRepository) FindByID(id int) (*Order, خطأ) {
    o := &Order{}
    err := r.db.QueryRow(
        "SELECT id, user_id, product_id, quantity, total, حالة, created_at FROM orders WHERE id = ?", id,
    ).Scan(&o.ID, &o.UserID, &o.ProductID, &o.Quantity, &o.Total, &o.Status, &o.CreatedAt)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    return o, err
}

// ---------- Service (internal/service) ----------

type OrderService struct {
    productRepo *ProductRepository
    orderRepo   *OrderRepository
}

func NewOrderService(productRepo *ProductRepository, orderRepo *OrderRepository) *OrderService {
    return &OrderService{
        productRepo: productRepo,
        orderRepo:   orderRepo,
    }
}

type PlaceOrderInput struct {
    UserID    int
    ProductID int
    Quantity  int
}

var (
    ErrProductNotFound  = &AppError{Code: "PRODUCT_NOT_FOUND", Message: "product not found", HTTPStatus: 404}
    ErrInsufficientStock = &AppError{Code: "INSUFFICIENT_STOCK", Message: "insufficient stock", HTTPStatus: 409}
)

type AppError struct {
    Code       سلسلة `json:"code"`
    Message    سلسلة `json:"message"`
    HTTPStatus int    `json:"-"`
}

func (e *AppError) Error() سلسلة {
    return e.Message
}

func (s *OrderService) PlaceOrder(input PlaceOrderInput) (*Order, خطأ) {
    product, err := s.productRepo.FindByID(input.ProductID)
    if err != nil {
        return nil, err
    }
    if product == nil {
        return nil, ErrProductNotFound
    }

    total := product.Price * float64(input.Quantity)

    order := &Order{
        UserID:    input.UserID,
        ProductID: input.ProductID,
        Quantity:  input.Quantity,
        Total:     total,
        Status:    "created",
    }

    if err := s.orderRepo.Create(order); err != nil {
        return nil, err
    }

    return order, nil
}

// ---------- Handler (internal/handler) ----------

type OrderHandler struct {
    svc *OrderService
}

func NewOrderHandler(svc *OrderService) *OrderHandler {
    return &OrderHandler{svc: svc}
}

func (h *OrderHandler) Register(mux *http.ServeMux, basePath سلسلة) {
    mux.HandleFunc("POST "+basePath, h.PlaceOrder)
    mux.HandleFunc("GET "+basePath+"/{id}", h.GetOrder)
}

type placeOrderRequest struct {
    ProductID int `json:"product_id"`
    Quantity  int `json:"quantity"`
}

func (h *OrderHandler) PlaceOrder(w http.ResponseWriter, r *http.Request) {
    // Get user ID from Context (injected by auth وسيط)
    userID, ok := r.Context().Value("user_id").(int)
    if !ok {
        writeError(w, http.StatusUnauthorized, "not authenticated")
        return
    }

    var req placeOrderRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON")
        return
    }

    order, err := h.svc.PlaceOrder(PlaceOrderInput{
        UserID:    userID,
        ProductID: req.ProductID,
        Quantity:  req.Quantity,
    })

    if appErr, ok := err.(*AppError); ok {
        writeError(w, appErr.HTTPStatus, appErr.Message)
        return
    }
    if err != nil {
        writeError(w, http.StatusInternalServerError, "internal خطأ")
        return
    }

    writeJSON(w, http.StatusCreated, order)
}

func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
    // ... business logic
}

func writeJSON(w http.ResponseWriter, حالة int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(حالة)
    json.NewEncoder(w).Encode(data)
}

func writeError(w http.ResponseWriter, حالة int, msg سلسلة) {
    writeJSON(w, حالة, map[سلسلة]سلسلة{"خطأ": msg})
}

// ---------- Main (dependency injection entry) ----------

func main() {
    db, err := sql.Open("sqlite3", "./shop.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // Initialize tables
    db.Exec(`CREATE TABLE IF NOT EXISTS products (
        id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, price REAL
    )`)
    db.Exec(`CREATE TABLE IF NOT EXISTS orders (
        id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER,
        product_id INTEGER, quantity INTEGER, total REAL,
        حالة TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )`)

    // Dependency injection
    productRepo := NewProductRepository(db)
    orderRepo := NewOrderRepository(db)
    orderSvc := NewOrderService(productRepo, orderRepo)
    orderHandler := NewOrderHandler(orderSvc)

    mux := http.NewServeMux()
    orderHandler.Register(mux, "/api/v1/orders")

    // Health check
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        writeJSON(w, http.StatusOK, map[سلسلة]سلسلة{"حالة": "ok"})
    })

    خادم := &http.Server{
        Addr:    ":8080",
        Handler: mux,
    }

    // Graceful shutdown
    go func() {
        sigCh := make(chan os.Signal, 1)
        signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
        <-sigCh
        log.Println("Shutting down...")
        ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        defer cancel()
        خادم.Shutdown(ctx)
    }()

    log.Println("E-commerce service listening on :8080")
    if err := خادم.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatal(err)
    }
}
225 logic lines (exceeds 40-line limit, display only)
100%
graph TB
    subgraph Handler
        H[HTTP Handler]
    end
    subgraph Service
        S[Business Logic]
    end
    subgraph Repository
        R[Data Access]
    end
    subgraph Model
        M[Domain Structs]
    end
    subgraph External ["External Dependencies"]
        DB[(Database)]
    end

    H -->|calls| S
    S -->|calls| R
    R -->|queries| DB
    H --- M
    S --- M
    R --- M

    style Handler fill:#e1f5fe
    style Service fill:#fff3e0
    style Repository fill:#e8f5e9
    style Model fill:#f3e5f5
🔥 Common Mistake: Don't skip any layers. Even if your Service only calls a single طريقة in the Repository, you must go through the Service layer—business rules will expand in the future. Skipping layers will result in business logic being scattered throughout the Handler, making it impossible to unit test.


❓ FAQ

Q What is Clean Architecture?
A A layered architectural pattern—from the outside in: Handler (Interface Layer) → Service (Use Case Layer) → Repository (Data Layer) → Model (Domain Layer). Core principle: Dependencies flow from the outer layers to the inner layers; inner layers are unaware of the existence of outer layers. Outer layers implement interfaces, while inner layers define interfaces.
Q How are the layers in the 4-layer model organized?
A (1) Handler: HTTP parsing/استجابة, parameter validation; (2) Service: Business rules, multi-step orchestration, transactions; (3) Repository: Database CRUD operations, ذاكرة مخبأة access, external APIs; (4) Model: Data structure definitions. Each layer depends only on the layer below it and is decoupled through interfaces.
Q How is dependency injection implemented?
A Constructor injection is the most common approach: NewXxx(dep1, dep2) *Xxx. All dependencies are created and assembled in the main دالة (or in the code generated by Wire). Advantages: (1) Clear dependency relationships; (2) Compile-time checks; (3) Ability to pass mocks during unit testing.
Q Why use the internal directory?
A The Go compiler enforces that packages in the internal directory can only be imported by code in their parent directory. This provides true encapsulation—external users cannot import internal packages. In large projects, this prevents architectural violations (such as handlers being directly imported into repository).
Q How should خطأ types be designed?
A Define custom خطأ types that include Code, Message, and HTTPStatus fields. The Service layer returns business errors (such as ErrProductNotFound), and the Handler layer maps these errors to HTTP حالة codes and JSON responses. Benefits: A unified خطأ format makes خطأ types immediately clear.
Q Is it necessary to use interfaces between layers?
A It is recommended. Go's implicit interfaces make dependency inversion feel natural—the Repository defines an interface, the Handler depends on that interface, and the concrete implementation is injected via the constructor. Benefits: (1) Unit tests can use mocks; (2) Switching implementations (e.g., SQLite → MySQL) requires no changes to the caller.
Q Do small projects also need a 4-tier architecture?
A No. The project's scale determines the depth of the architecture—use a flat structure for projects under 500 lines of code, a 2-tier structure (handler + repository) for 500–5,000 lines, and a 4-tier structure for 5,000+ lines. It's recommended to start with a two-tier architecture and gradually add layers as the project grows. Avoid over-engineering.

📖 Summary


📝 Exercises

  1. Basic (Difficulty ⭐): Create a project skeleton containing the following directories: cmd/خادم/main.go, internal/handler/, internal/service/, internal/repository/, and pkg/model/. Implement a simple HealthHandler that returns {"حالة": "ok"}.

  2. Advanced (Difficulty ⭐⭐): Implement Product Category Management using a 4-tier architecture. Requirements: (1) Model: Category(id, name, parent_id); (2) Handler: CRUD endpoints; (3) Service: Validate that category names are unique and prevent circular references; (4) Repository: Implemented using SQLite; (5) The main دالة assembles the components via constructor injection.

  3. Challenge (Difficulty ⭐⭐⭐): Refactor the library management system from Lesson 22 into a four-tier architecture. Requirements: (1) Move the code to the cmd/internal/pkg directory; (2) The Handler layer should handle only HTTP; (3) The Service layer must include complete business validation; (4) The Repository layer must use SQLite; (5) Expose the Repository as an interface to enable mock testing; (6) Write unit tests (using a mock repository) to test the business rules in the Service layer.

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏