Comprehensive Project (Part 1)

Comprehensive Project (Part 1)

Building a complete Go project from scratch — Blog API system.

In this lesson we'll build a Blog Post Management System (Blog API), covering requirements analysis, directory design, data models, business logic, error handling, and unit tests. The next lesson will complete the HTTP routing and middleware parts.


Project Requirements

Feature List

Module Features Description
Articles Create, query, list, update, delete Full CRUD coverage
Categories Create, query, list Articles belong to categories
Tags Create, query, list Articles can be associated with multiple tags
Users Register, query Simplified version, no login authentication

Non-Functional Requirements


System Architecture Design

Adopts the classic three-layer architecture:

TEXT
┌─────────────────────────────────────────────┐
│              Handler (HTTP Layer)            │
│   Receive request → Parse params → Call Service → Return │
└──────────────────────┬──────────────────────┘
                       │
┌──────────────────────▼──────────────────────┐
│             Service (Business Layer)         │
│   Business logic → Validate params → Call Repository     │
└──────────────────────┬──────────────────────┘
                       │
┌──────────────────────▼──────────────────────┐
│           Repository (Data Layer)            │
│   Data access → In-memory storage / Database │
└─────────────────────────────────────────────┘

Each layer only depends on the layer below, decoupled through interfaces for easy testing and implementation replacement.


Project Directory Structure

TEXT
blog-api/
├── cmd/
│   └── server/
│       └── main.go            # Program entry point
├── internal/
│   ├── handler/               # HTTP handlers (implemented in Lesson 30)
│   │   ├── article.go
│   │   ├── category.go
│   │   └── response.go
│   ├── service/               # Business logic layer
│   │   ├── article.go
│   │   ├── category.go
│   │   └── service.go
│   ├── repository/            # Data access layer
│   │   ├── article.go
│   │   ├── category.go
│   │   └── store.go
│   └── model/                 # Data models
│       ├── article.go
│       ├── category.go
│       └── user.go
├── pkg/
│   └── errcode/               # Unified error codes
│       └── errcode.go
├── go.mod
└── README.md

Directory responsibilities:

Directory Responsibility Visibility
cmd/ Program entry point, each subdirectory corresponds to an executable Public
internal/ Core project code, external packages cannot import Private
pkg/ Utility packages reusable by external projects Public

Step 1: Initialize the Project

BASH
mkdir -p blog-api && cd blog-api
go mod init blog-api

Create directory structure:

BASH
mkdir -p cmd/server
mkdir -p internal/model internal/repository internal/service internal/handler
mkdir -p pkg/errcode

Step 2: Define Data Models

User Model

GO
// internal/model/user.go
package model

import "time"

// User user model
type User struct {
	ID        int64     `json:"id"`
	Username  string    `json:"username"`
	Email     string    `json:"email"`
	CreatedAt time.Time `json:"created_at"`
}

Category Model

GO
// internal/model/category.go
package model

import "time"

// Category article category
type Category struct {
	ID        int64     `json:"id"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"created_at"`
}

Article Model

GO
// internal/model/article.go
package model

import "time"

// Article article model
type Article struct {
	ID         int64     `json:"id"`
	Title      string    `json:"title"`
	Content    string    `json:"content"`
	AuthorID   int64     `json:"author_id"`
	CategoryID int64     `json:"category_id"`
	Tags       []string  `json:"tags"`
	Status     int8      `json:"status"` // 0=draft 1=published
	CreatedAt  time.Time `json:"created_at"`
	UpdatedAt  time.Time `json:"updated_at"`
}

// CreateArticleRequest request for creating an article
type CreateArticleRequest struct {
	Title      string   `json:"title"`
	Content    string   `json:"content"`
	AuthorID   int64    `json:"author_id"`
	CategoryID int64    `json:"category_id"`
	Tags       []string `json:"tags"`
}

// UpdateArticleRequest request for updating an article
type UpdateArticleRequest struct {
	Title      *string  `json:"title,omitempty"`
	Content    *string  `json:"content,omitempty"`
	CategoryID *int64   `json:"category_id,omitempty"`
	Tags       []string `json:"tags,omitempty"`
	Status     *int8    `json:"status,omitempty"`
}

// ListRequest paginated query request
type ListRequest struct {
	Page     int `json:"page"`
	PageSize int `json:"page_size"`
}

// ListResponse paginated query response
type ListResponse struct {
	Items      interface{} `json:"items"`
	Total      int64       `json:"total"`
	Page       int         `json:"page"`
	PageSize   int         `json:"page_size"`
	TotalPages int         `json:"total_pages"`
}

Code analysis:


Step 3: Unified Error Codes

GO
// pkg/errcode/errcode.go
package errcode

import "fmt"

// AppError application error
type AppError struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

// Error implements the error interface
func (e *AppError) Error() string {
	return fmt.Sprintf("code=%d, message=%s", e.Code, e.Message)
}

// New creates an application error
func New(code int, message string) *AppError {
	return &AppError{Code: code, Message: message}
}

// Predefined error codes
var (
	ErrNotFound        = New(10001, "Resource not found")
	ErrInvalidParam    = New(10002, "Invalid parameters")
	ErrDuplicate       = New(10003, "Resource already exists")
	ErrInternal        = New(10004, "Internal error")
	ErrArticleNotFound = New(20001, "Article not found")
	ErrCategoryNotFound = New(20002, "Category not found")
	ErrUserNotFound    = New(20003, "User not found")
	ErrTitleRequired   = New(20004, "Title cannot be empty")
	ErrContentRequired = New(20005, "Content cannot be empty")
)

Code analysis:


Step 4: Data Access Layer (Repository)

Storage Interface

GO
// internal/repository/store.go
package repository

import "blog-api/internal/model"

// Store unified storage interface (interface-oriented programming)
type Store interface {
	ArticleRepo() ArticleRepository
	CategoryRepo() CategoryRepository
	UserRepo() UserRepository
}

// ArticleRepository article data access interface
type ArticleRepository interface {
	Create(article *model.Article) error
	GetByID(id int64) (*model.Article, error)
	List(page, pageSize int) ([]*model.Article, int64, error)
	Update(article *model.Article) error
	Delete(id int64) error
}

// CategoryRepository category data access interface
type CategoryRepository interface {
	Create(category *model.Category) error
	GetByID(id int64) (*model.Category, error)
	List() ([]*model.Category, error)
}

// UserRepository user data access interface
type UserRepository interface {
	Create(user *model.User) error
	GetByID(id int64) (*model.User, error)
}

In-Memory Implementation

GO
// internal/repository/memory_store.go
package repository

import (
	"fmt"
	"sync"
	"sync/atomic"

	"blog-api/internal/model"
)

// MemoryStore in-memory storage implementation
type MemoryStore struct {
	articles   map[int64]*model.Article
	categories map[int64]*model.Category
	users      map[int64]*model.User
	mu         sync.RWMutex
	nextID     atomic.Int64
}

// NewMemoryStore creates in-memory storage
func NewMemoryStore() *MemoryStore {
	return &MemoryStore{
		articles:   make(map[int64]*model.Article),
		categories: make(map[int64]*model.Category),
		users:      make(map[int64]*model.User),
	}
}

func (s *MemoryStore) next() int64 {
	return s.nextID.Add(1)
}

// ArticleRepo returns the article repository
func (s *MemoryStore) ArticleRepo() ArticleRepository {
	return &memoryArticleRepo{store: s}
}

// CategoryRepo returns the category repository
func (s *MemoryStore) CategoryRepo() CategoryRepository {
	return &memoryCategoryRepo{store: s}
}

// UserRepo returns the user repository
func (s *MemoryStore) UserRepo() UserRepository {
	return &memoryUserRepo{store: s}
}

// ---- Article Repository Implementation ----

type memoryArticleRepo struct {
	store *MemoryStore
}

func (r *memoryArticleRepo) Create(article *model.Article) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	article.ID = r.store.next()
	r.store.articles[article.ID] = article
	return nil
}

func (r *memoryArticleRepo) GetByID(id int64) (*model.Article, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	article, ok := r.store.articles[id]
	if !ok {
		return nil, fmt.Errorf("article %d not found", id)
	}
	return article, nil
}

func (r *memoryArticleRepo) List(page, pageSize int) ([]*model.Article, int64, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	// Collect all articles
	all := make([]*model.Article, 0, len(r.store.articles))
	for _, a := range r.store.articles {
		all = append(all, a)
	}

	total := int64(len(all))
	start := (page - 1) * pageSize
	if start >= len(all) {
		return []*model.Article{}, total, nil
	}
	end := start + pageSize
	if end > len(all) {
		end = len(all)
	}

	return all[start:end], total, nil
}

func (r *memoryArticleRepo) Update(article *model.Article) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	if _, ok := r.store.articles[article.ID]; !ok {
		return fmt.Errorf("article %d not found", article.ID)
	}
	r.store.articles[article.ID] = article
	return nil
}

func (r *memoryArticleRepo) Delete(id int64) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	if _, ok := r.store.articles[id]; !ok {
		return fmt.Errorf("article %d not found", id)
	}
	delete(r.store.articles, id)
	return nil
}

// ---- Category Repository Implementation ----

type memoryCategoryRepo struct {
	store *MemoryStore
}

func (r *memoryCategoryRepo) Create(category *model.Category) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	category.ID = r.store.next()
	r.store.categories[category.ID] = category
	return nil
}

func (r *memoryCategoryRepo) GetByID(id int64) (*model.Category, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	cat, ok := r.store.categories[id]
	if !ok {
		return nil, fmt.Errorf("category %d not found", id)
	}
	return cat, nil
}

func (r *memoryCategoryRepo) List() ([]*model.Category, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	all := make([]*model.Category, 0, len(r.store.categories))
	for _, c := range r.store.categories {
		all = append(all, c)
	}
	return all, nil
}

// ---- User Repository Implementation ----

type memoryUserRepo struct {
	store *MemoryStore
}

func (r *memoryUserRepo) Create(user *model.User) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	user.ID = r.store.next()
	r.store.users[user.ID] = user
	return nil
}

func (r *memoryUserRepo) GetByID(id int64) (*model.User, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	user, ok := r.store.users[id]
	if !ok {
		return nil, fmt.Errorf("user %d not found", id)
	}
	return user, nil
}

Code analysis:


Step 5: Business Logic Layer (Service)

Service Interface

GO
// internal/service/service.go
package service

import "blog-api/internal/model"

// ArticleService article business interface
type ArticleService interface {
	Create(req *model.CreateArticleRequest) (*model.Article, error)
	GetByID(id int64) (*model.Article, error)
	List(page, pageSize int) (*model.ListResponse, error)
	Update(id int64, req *model.UpdateArticleRequest) (*model.Article, error)
	Delete(id int64) error
}

// CategoryService category business interface
type CategoryService interface {
	Create(name string) (*model.Category, error)
	GetByID(id int64) (*model.Category, error)
	List() ([]*model.Category, error)
}

Article Service Implementation

GO
// internal/service/article.go
package service

import (
	"strings"
	"time"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/pkg/errcode"
)

type articleService struct {
	store repository.Store
}

// NewArticleService creates an article service
func NewArticleService(store repository.Store) ArticleService {
	return &articleService{store: store}
}

// Create creates an article
func (s *articleService) Create(req *model.CreateArticleRequest) (*model.Article, error) {
	// Parameter validation
	if strings.TrimSpace(req.Title) == "" {
		return nil, errcode.ErrTitleRequired
	}
	if strings.TrimSpace(req.Content) == "" {
		return nil, errcode.ErrContentRequired
	}

	// Verify author exists
	_, err := s.store.UserRepo().GetByID(req.AuthorID)
	if err != nil {
		return nil, errcode.ErrUserNotFound
	}

	// Verify category exists
	_, err = s.store.CategoryRepo().GetByID(req.CategoryID)
	if err != nil {
		return nil, errcode.ErrCategoryNotFound
	}

	// Build article object
	now := time.Now()
	article := &model.Article{
		Title:      req.Title,
		Content:    req.Content,
		AuthorID:   req.AuthorID,
		CategoryID: req.CategoryID,
		Tags:       req.Tags,
		Status:     0, // Default draft
		CreatedAt:  now,
		UpdatedAt:  now,
	}

	// Persist
	if err := s.store.ArticleRepo().Create(article); err != nil {
		return nil, errcode.ErrInternal
	}

	return article, nil
}

// GetByID retrieves an article by ID
func (s *articleService) GetByID(id int64) (*model.Article, error) {
	article, err := s.store.ArticleRepo().GetByID(id)
	if err != nil {
		return nil, errcode.ErrArticleNotFound
	}
	return article, nil
}

// List returns paginated article list
func (s *articleService) List(page, pageSize int) (*model.ListResponse, error) {
	// Default parameter values
	if page <= 0 {
		page = 1
	}
	if pageSize <= 0 || pageSize > 100 {
		pageSize = 10
	}

	articles, total, err := s.store.ArticleRepo().List(page, pageSize)
	if err != nil {
		return nil, errcode.ErrInternal
	}

	// Calculate total pages
	totalPages := int(total) / pageSize
	if int(total)%pageSize > 0 {
		totalPages++
	}

	return &model.ListResponse{
		Items:      articles,
		Total:      total,
		Page:       page,
		PageSize:   pageSize,
		TotalPages: totalPages,
	}, nil
}

// Update updates an article
func (s *articleService) Update(id int64, req *model.UpdateArticleRequest) (*model.Article, error) {
	// Query existing article
	article, err := s.store.ArticleRepo().GetByID(id)
	if err != nil {
		return nil, errcode.ErrArticleNotFound
	}

	// Update fields as needed (only update non-empty fields that were sent)
	if req.Title != nil {
		if strings.TrimSpace(*req.Title) == "" {
			return nil, errcode.ErrTitleRequired
		}
		article.Title = *req.Title
	}
	if req.Content != nil {
		if strings.TrimSpace(*req.Content) == "" {
			return nil, errcode.ErrContentRequired
		}
		article.Content = *req.Content
	}
	if req.CategoryID != nil {
		// Verify category exists
		_, err := s.store.CategoryRepo().GetByID(*req.CategoryID)
		if err != nil {
			return nil, errcode.ErrCategoryNotFound
		}
		article.CategoryID = *req.CategoryID
	}
	if req.Tags != nil {
		article.Tags = req.Tags
	}
	if req.Status != nil {
		article.Status = *req.Status
	}
	article.UpdatedAt = time.Now()

	// Persist
	if err := s.store.ArticleRepo().Update(article); err != nil {
		return nil, errcode.ErrInternal
	}

	return article, nil
}

// Delete deletes an article
func (s *articleService) Delete(id int64) error {
	_, err := s.store.ArticleRepo().GetByID(id)
	if err != nil {
		return errcode.ErrArticleNotFound
	}

	if err := s.store.ArticleRepo().Delete(id); err != nil {
		return errcode.ErrInternal
	}

	return nil
}

Code analysis:

Category Service Implementation

GO
// internal/service/category.go
package service

import (
	"strings"
	"time"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/pkg/errcode"
)

type categoryService struct {
	store repository.Store
}

// NewCategoryService creates a category service
func NewCategoryService(store repository.Store) CategoryService {
	return &categoryService{store: store}
}

// Create creates a category
func (s *categoryService) Create(name string) (*model.Category, error) {
	if strings.TrimSpace(name) == "" {
		return nil, errcode.New(10002, "Category name cannot be empty")
	}

	category := &model.Category{
		Name:      name,
		CreatedAt: time.Now(),
	}

	if err := s.store.CategoryRepo().Create(category); err != nil {
		return nil, errcode.ErrInternal
	}

	return category, nil
}

// GetByID retrieves a category by ID
func (s *categoryService) GetByID(id int64) (*model.Category, error) {
	cat, err := s.store.CategoryRepo().GetByID(id)
	if err != nil {
		return nil, errcode.ErrCategoryNotFound
	}
	return cat, nil
}

// List returns all categories
func (s *categoryService) List() ([]*model.Category, error) {
	cats, err := s.store.CategoryRepo().List()
	if err != nil {
		return nil, errcode.ErrInternal
	}
	return cats, nil
}

Step 6: Program Entry Point

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

import (
	"fmt"
	"log"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/internal/service"
)

func main() {
	// Initialize storage
	store := repository.NewMemoryStore()

	// Initialize services
	articleSvc := service.NewArticleService(store)
	categorySvc := service.NewCategoryService(store)

	// Seed test data
	seedData(store, articleSvc, categorySvc)

	// Verify business logic
	runDemo(articleSvc, categorySvc)
}

// seedData populates test data
func seedData(store *repository.MemoryStore, articleSvc service.ArticleService, categorySvc service.CategoryService) {
	// Create user
	user := &model.User{
		Username: "alice",
		Email:    "alice@example.com",
	}
	if err := store.UserRepo().Create(user); err != nil {
		log.Fatalf("Failed to create user: %v", err)
	}
	fmt.Printf("✓ User created: ID=%d, Username=%s\n", user.ID, user.Username)

	// Create categories
	goCategory, _ := categorySvc.Create("Go Language")
	pythonCategory, _ := categorySvc.Create("Python")
	fmt.Printf("✓ Category created: ID=%d, Name=%s\n", goCategory.ID, goCategory.Name)
	fmt.Printf("✓ Category created: ID=%d, Name=%s\n", pythonCategory.ID, pythonCategory.Name)

	// Create articles
	article1, err := articleSvc.Create(&model.CreateArticleRequest{
		Title:      "Introduction to Go Concurrency",
		Content:    "This article introduces the basic usage of goroutines and channels...",
		AuthorID:   user.ID,
		CategoryID: goCategory.ID,
		Tags:       []string{"go", "concurrency", "goroutine"},
	})
	if err != nil {
		log.Fatalf("Failed to create article: %v", err)
	}
	fmt.Printf("✓ Article created: ID=%d, Title=%s\n", article1.ID, article1.Title)

	article2, err := articleSvc.Create(&model.CreateArticleRequest{
		Title:      "Go Interface Design Patterns",
		Content:    "Good interface design is key to Go programs...",
		AuthorID:   user.ID,
		CategoryID: goCategory.ID,
		Tags:       []string{"go", "interface", "design-patterns"},
	})
	if err != nil {
		log.Fatalf("Failed to create article: %v", err)
	}
	fmt.Printf("✓ Article created: ID=%d, Title=%s\n", article2.ID, article2.Title)
}

// runDemo demonstrates core functionality
func runDemo(articleSvc service.ArticleService, categorySvc service.CategoryService) {
	fmt.Println("\n========== Feature Demo ==========")

	// Query single article
	article, err := articleSvc.GetByID(3)
	if err != nil {
		log.Printf("Failed to query article: %v", err)
	} else {
		fmt.Printf("\n📄 Query article: %s\n", article.Title)
		fmt.Printf("   Content: %s\n", article.Content)
		fmt.Printf("   Tags: %v\n", article.Tags)
	}

	// Paginated query
	fmt.Println("\n📋 Article list (page 1, 10 per page):")
	list, err := articleSvc.List(1, 10)
	if err != nil {
		log.Printf("Failed to query list: %v", err)
	} else {
		articles := list.Items.([]*model.Article)
		for _, a := range articles {
			fmt.Printf("   [%d] %s (tags: %v)\n", a.ID, a.Title, a.Tags)
		}
		fmt.Printf("   Total %d articles, %d pages\n", list.Total, list.TotalPages)
	}

	// Update article
	newTitle := "Advanced Go Concurrency"
	newStatus := int8(1)
	updated, err := articleSvc.Update(3, &model.UpdateArticleRequest{
		Title:  &newTitle,
		Status: &newStatus,
	})
	if err != nil {
		log.Printf("Failed to update article: %v", err)
	} else {
		fmt.Printf("\n✏️  Article updated: %s (status: %d)\n", updated.Title, updated.Status)
	}

	// Delete article
	err = articleSvc.Delete(4)
	if err != nil {
		log.Printf("Failed to delete article: %v", err)
	} else {
		fmt.Println("\n🗑️  Article ID=4 deleted")
	}

	// Verify query after deletion
	_, err = articleSvc.GetByID(4)
	if err != nil {
		fmt.Printf("   Query deleted article: %v ✓\n", err)
	}

	// Category list
	categories, _ := categorySvc.List()
	fmt.Println("\n📂 Category list:")
	for _, c := range categories {
		fmt.Printf("   [%d] %s\n", c.ID, c.Name)
	}

	// Error handling demo
	fmt.Println("\n❌ Error handling demo:")

	// Empty title
	_, err = articleSvc.Create(&model.CreateArticleRequest{
		Title:   "",
		Content: "test",
	})
	fmt.Printf("   Empty title: %v\n", err)

	// Non-existent author
	_, err = articleSvc.Create(&model.CreateArticleRequest{
		Title:      "test",
		Content:    "test",
		AuthorID:   999,
		CategoryID: 2,
	})
	fmt.Printf("   Non-existent author: %v\n", err)

	// Query non-existent article
	_, err = articleSvc.GetByID(999)
	fmt.Printf("   Non-existent article: %v\n", err)
}

Running the Demo

BASH
go run cmd/server/main.go

Expected output:

TEXT
✓ User created: ID=1, Username=alice
✓ Category created: ID=2, Name=Go Language
✓ Category created: ID=3, Name=Python
✓ Article created: ID=4, Title=Introduction to Go Concurrency
✓ Article created: ID=5, Title=Go Interface Design Patterns

========== Feature Demo ==========

📄 Query article: Introduction to Go Concurrency
   Content: This article introduces the basic usage of goroutines and channels...
   Tags: [go concurrency goroutine]

📋 Article list (page 1, 10 per page):
   [4] Introduction to Go Concurrency (tags: [go concurrency goroutine])
   [5] Go Interface Design Patterns (tags: [go interface design-patterns])
   Total 2 articles, 1 pages

✏️  Article updated: Advanced Go Concurrency (status: 1)

🗑️  Article ID=5 deleted
   Query deleted article: code=20001, message=Article not found ✓

📂 Category list:
   [2] Go Language
   [3] Python

❌ Error handling demo:
   Empty title: code=20004, message=Title cannot be empty
   Non-existent author: code=20003, message=User not found
   Non-existent article: code=20001, message=Article not found

Step 7: Unit Tests

Repository Layer Tests

GO
// internal/repository/memory_store_test.go
package repository

import (
	"testing"
	"time"

	"blog-api/internal/model"
)

// Helper function: create test storage
func newTestStore() *MemoryStore {
	return NewMemoryStore()
}

func TestArticleRepository_Create(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	article := &model.Article{
		Title:     "Test Article",
		Content:   "Test Content",
		AuthorID:  1,
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	}

	err := repo.Create(article)
	if err != nil {
		t.Fatalf("Failed to create article: %v", err)
	}

	// ID should be auto-assigned
	if article.ID == 0 {
		t.Fatal("Article ID should not be 0")
	}
}

func TestArticleRepository_GetByID(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	// Create article
	article := &model.Article{
		Title:   "Test Article",
		Content: "Test Content",
	}
	_ = repo.Create(article)

	// Query
	got, err := repo.GetByID(article.ID)
	if err != nil {
		t.Fatalf("Failed to query article: %v", err)
	}
	if got.Title != "Test Article" {
		t.Errorf("Expected title %q, got %q", "Test Article", got.Title)
	}

	// Query non-existent article
	_, err = repo.GetByID(999)
	if err == nil {
		t.Error("Querying non-existent article should return error")
	}
}

func TestArticleRepository_List(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	// Create 15 articles
	for i := 0; i < 15; i++ {
		_ = repo.Create(&model.Article{
			Title:   "Article",
			Content: "Content",
		})
	}

	// First page
	articles, total, err := repo.List(1, 10)
	if err != nil {
		t.Fatalf("Failed to query list: %v", err)
	}
	if total != 15 {
		t.Errorf("Expected total 15, got %d", total)
	}
	if len(articles) != 10 {
		t.Errorf("Expected 10 items, got %d", len(articles))
	}

	// Second page
	articles, _, _ = repo.List(2, 10)
	if len(articles) != 5 {
		t.Errorf("Expected 5 items, got %d", len(articles))
	}

	// Out of range page
	articles, _, _ = repo.List(100, 10)
	if len(articles) != 0 {
		t.Errorf("Out of range should return empty, got %d items", len(articles))
	}
}

func TestArticleRepository_Update(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	article := &model.Article{
		Title:   "Original Title",
		Content: "Original Content",
	}
	_ = repo.Create(article)

	// Update
	article.Title = "New Title"
	err := repo.Update(article)
	if err != nil {
		t.Fatalf("Update failed: %v", err)
	}

	// Verify
	got, _ := repo.GetByID(article.ID)
	if got.Title != "New Title" {
		t.Errorf("Expected %q, got %q", "New Title", got.Title)
	}
}

func TestArticleRepository_Delete(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	article := &model.Article{
		Title:   "To Be Deleted",
		Content: "Content",
	}
	_ = repo.Create(article)

	// Delete
	err := repo.Delete(article.ID)
	if err != nil {
		t.Fatalf("Delete failed: %v", err)
	}

	// Verify deleted
	_, err = repo.GetByID(article.ID)
	if err == nil {
		t.Error("Querying deleted article should return error")
	}
}

Service Layer Tests

GO
// internal/service/article_test.go
package service

import (
	"testing"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/pkg/errcode"
)

// Helper function: create service with test data
func newTestArticleService() (ArticleService, repository.Store) {
	store := repository.NewMemoryStore()

	// Create test user
	_ = store.UserRepo().Create(&model.User{
		Username: "testuser",
		Email:    "test@example.com",
	})

	// Create test category
	_ = store.CategoryRepo().Create(&model.Category{
		Name: "Go",
	})

	return NewArticleService(store), store
}

func TestArticleService_Create_Success(t *testing.T) {
	svc, _ := newTestArticleService()

	article, err := svc.Create(&model.CreateArticleRequest{
		Title:      "Test Article",
		Content:    "Test Content",
		AuthorID:   1,
		CategoryID: 2,
		Tags:       []string{"test"},
	})
	if err != nil {
		t.Fatalf("Failed to create article: %v", err)
	}
	if article.Title != "Test Article" {
		t.Errorf("Expected %q, got %q", "Test Article", article.Title)
	}
	if article.Status != 0 {
		t.Errorf("Default status should be 0, got %d", article.Status)
	}
}

func TestArticleService_Create_EmptyTitle(t *testing.T) {
	svc, _ := newTestArticleService()

	_, err := svc.Create(&model.CreateArticleRequest{
		Title:   "",
		Content: "Content",
	})

	// Type assertion to check error code
	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("Expected AppError, got %T", err)
	}
	if appErr.Code != errcode.ErrTitleRequired.Code {
		t.Errorf("Expected error code %d, got %d", errcode.ErrTitleRequired.Code, appErr.Code)
	}
}

func TestArticleService_Create_InvalidAuthor(t *testing.T) {
	svc, _ := newTestArticleService()

	_, err := svc.Create(&model.CreateArticleRequest{
		Title:      "Article",
		Content:    "Content",
		AuthorID:   999,
		CategoryID: 2,
	})

	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("Expected AppError, got %T", err)
	}
	if appErr.Code != errcode.ErrUserNotFound.Code {
		t.Errorf("Expected error code %d, got %d", errcode.ErrUserNotFound.Code, appErr.Code)
	}
}

func TestArticleService_Create_InvalidCategory(t *testing.T) {
	svc, _ := newTestArticleService()

	_, err := svc.Create(&model.CreateArticleRequest{
		Title:      "Article",
		Content:    "Content",
		AuthorID:   1,
		CategoryID: 999,
	})

	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("Expected AppError, got %T", err)
	}
	if appErr.Code != errcode.ErrCategoryNotFound.Code {
		t.Errorf("Expected error code %d, got %d", errcode.ErrCategoryNotFound.Code, appErr.Code)
	}
}

func TestArticleService_Update_PartialUpdate(t *testing.T) {
	svc, _ := newTestArticleService()

	// First create an article
	article, _ := svc.Create(&model.CreateArticleRequest{
		Title:      "Original Title",
		Content:    "Original Content",
		AuthorID:   1,
		CategoryID: 2,
	})

	// Only update title
	newTitle := "New Title"
	updated, err := svc.Update(article.ID, &model.UpdateArticleRequest{
		Title: &newTitle,
	})
	if err != nil {
		t.Fatalf("Update failed: %v", err)
	}
	if updated.Title != "New Title" {
		t.Errorf("Expected %q, got %q", "New Title", updated.Title)
	}
	if updated.Content != "Original Content" {
		t.Errorf("Content should not be modified, got %q", updated.Content)
	}
}

func TestArticleService_Update_NotFound(t *testing.T) {
	svc, _ := newTestArticleService()

	newTitle := "test"
	_, err := svc.Update(999, &model.UpdateArticleRequest{
		Title: &newTitle,
	})

	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("Expected AppError, got %T", err)
	}
	if appErr.Code != errcode.ErrArticleNotFound.Code {
		t.Errorf("Expected error code %d, got %d", errcode.ErrArticleNotFound.Code, appErr.Code)
	}
}

func TestArticleService_Delete_Success(t *testing.T) {
	svc, _ := newTestArticleService()

	article, _ := svc.Create(&model.CreateArticleRequest{
		Title:      "To Be Deleted",
		Content:    "Content",
		AuthorID:   1,
		CategoryID: 2,
	})

	err := svc.Delete(article.ID)
	if err != nil {
		t.Fatalf("Delete failed: %v", err)
	}

	// Verify deleted
	_, err = svc.GetByID(article.ID)
	if err == nil {
		t.Error("Querying deleted article should return error")
	}
}

func TestArticleService_List_Pagination(t *testing.T) {
	svc, _ := newTestArticleService()

	// Create 25 articles
	for i := 0; i < 25; i++ {
		_, _ = svc.Create(&model.CreateArticleRequest{
			Title:      "Article",
			Content:    "Content",
			AuthorID:   1,
			CategoryID: 2,
		})
	}

	// First page
	resp, err := svc.List(1, 10)
	if err != nil {
		t.Fatalf("Query failed: %v", err)
	}
	if resp.Total != 25 {
		t.Errorf("Expected total 25, got %d", resp.Total)
	}
	if resp.TotalPages != 3 {
		t.Errorf("Expected total pages 3, got %d", resp.TotalPages)
	}

	// Default value handling
	resp, _ = svc.List(0, 0)
	if resp.Page != 1 {
		t.Errorf("page=0 should default to 1, got %d", resp.Page)
	}
	if resp.PageSize != 10 {
		t.Errorf("pageSize=0 should default to 10, got %d", resp.PageSize)
	}
}

Running Tests

BASH
go test ./internal/... -v -cover

Expected output:

TEXT
=== RUN   TestArticleRepository_Create
--- PASS: TestArticleRepository_Create (0.00s)
=== RUN   TestArticleRepository_GetByID
--- PASS: TestArticleRepository_GetByID (0.00s)
=== RUN   TestArticleRepository_List
--- PASS: TestArticleRepository_List (0.00s)
=== RUN   TestArticleRepository_Update
--- PASS: TestArticleRepository_Update (0.00s)
=== RUN   TestArticleRepository_Delete
--- PASS: TestArticleRepository_Delete (0.00s)
=== RUN   TestArticleService_Create_Success
--- PASS: TestArticleService_Create_Success (0.00s)
=== RUN   TestArticleService_Create_EmptyTitle
--- PASS: TestArticleService_Create_EmptyTitle (0.00s)
=== RUN   TestArticleService_Create_InvalidAuthor
--- PASS: TestArticleService_Create_InvalidAuthor (0.00s)
=== RUN   TestArticleService_Create_InvalidCategory
--- PASS: TestArticleService_Create_InvalidCategory (0.00s)
=== RUN   TestArticleService_Update_PartialUpdate
--- PASS: TestArticleService_Update_PartialUpdate (0.00s)
=== RUN   TestArticleService_Update_NotFound
--- PASS: TestArticleService_Update_NotFound (0.00s)
=== RUN   TestArticleService_Delete_Success
--- PASS: TestArticleService_Delete_Success (0.00s)
=== RUN   TestArticleService_List_Pagination
--- PASS: TestArticleService_List_Pagination (0.00s)
PASS
coverage: 82.4% of statements
ok      blog-api/internal/repository    0.002s
ok      blog-api/internal/service       0.002s

Code Summary

This lesson completed the core part of the Blog API:

Layer File Responsibility
Model internal/model/*.go Data structure definitions, request/response bodies
Repository internal/repository/*.go Data access interfaces + in-memory implementation
Service internal/service/*.go Business logic, parameter validation, error conversion
Error pkg/errcode/errcode.go Unified error code definitions
Entry cmd/server/main.go Program entry, initialization, demo

Key design decisions:

  1. Interface decoupling: Repository and Service both interact through interfaces, facilitating unit testing (mocking) and future storage replacement
  2. Pointer updates: UpdateArticleRequest uses pointer fields to distinguish zero values from unset values
  3. Unified errors: AppError encapsulates error codes and messages for unified frontend handling
  4. Concurrency safety: sync.RWMutex protects in-memory data, read operations use read locks, write operations use write locks

❓ FAQ

Q1: Why doesn't the Service layer depend directly on concrete Repository implementations?

Service depends on interfaces rather than concrete implementations, so unit tests can inject mock implementations without real storage. Also, if you later want to replace in-memory storage with MySQL, you just need to create a new mysqlArticleRepo implementing the ArticleRepository interface — the Service layer code doesn't need to change at all.

Q2: Why does UpdateArticleRequest use pointer fields?

In Go, the zero value of string is an empty string "". With regular fields, you can't distinguish between "user didn't send title" and "user sent empty title". With *string, nil means not sent, and non-nil means there's a value. This is a common pattern in Go API design.

Q3: Will the in-memory storage's sync.RWMutex become a performance bottleneck?

It's sufficient for this tutorial's demonstration scenario. If reads far outnumber writes (article list queries far outnumber creation/updates), RWMutex's read lock is shared — multiple read operations can execute concurrently, and only write operations are mutually exclusive. For production, use a database — connection pools are inherently concurrency-safe.

Q4: How to improve test coverage?

Currently at 80%+. For higher coverage, you can add: boundary value tests (very long titles, special characters), concurrency safety tests (multiple goroutines simultaneously creating articles), and complete test cases for the category service.


📖 Summary

In this lesson we built a complete Go project skeleton from scratch:

The next lesson will complete the HTTP Handler layer, integrating routing and middleware to expose business logic as a RESTful API.


📝 Exercises

Exercise 1: Add Independent Tag Management

Implement independent Repository and Service for Tags, supporting:

Exercise 2: Implement Article Search Functionality

Add a Search method to ArticleService:

Hint: For in-memory implementation, iterate the map and filter. Case-insensitive matching is required.

Exercise 3: Write Concurrency Safety Tests

Write a test case that uses 100 goroutines to simultaneously call ArticleService.Create, verifying:


Next Lesson: Comprehensive Project (Part 2) — HTTP Routing, Middleware, and Complete API

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%

🙏 帮我们做得更好

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

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