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
- Input parameter validation
- Unified error response format
- Paginated query support
- Unit test coverage > 70%
System Architecture Design
Adopts the classic three-layer architecture:
┌─────────────────────────────────────────────┐
│ 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
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
mkdir -p blog-api && cd blog-api
go mod init blog-api
Create directory structure:
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
// 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
// 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
// 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:
CreateArticleRequest: All fields are required when creating, uses a separate structUpdateArticleRequest: Uses pointers during updates to distinguish "not sent" from "empty value"ListRequest/Response: Unified pagination format, reused across all list endpoints
Step 3: Unified Error Codes
// 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:
AppErrorstruct contains error code and message, easy for frontend unified processing- Uses
varto predefine errors, avoiding runtime string concatenation - Error code ranges divided by module: 1xxxx for general, 2xxxx for article-related
Step 4: Data Access Layer (Repository)
Storage Interface
// 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
// 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:
MemoryStoreholds all data, usessync.RWMutexfor concurrency safety- Three repository methods each return their respective interface implementations
atomic.Int64generates auto-increment IDs without locking- In-memory implementation is convenient for development testing; can be replaced with database implementation later without modifying upper-layer code
Step 5: Business Logic Layer (Service)
Service Interface
// 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
// 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:
Createmethod sequentially does parameter validation → related resource verification → object construction → persistenceUpdateuses pointer fields for partial updates: only modifies fields that were sentListmethod has built-in default value protection to prevent invalid parameters- All lower-level errors are uniformly converted to
AppError, upper layers don't need to know about specific storage implementations
Category Service Implementation
// 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
// 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
go run cmd/server/main.go
Expected output:
✓ 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
// 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
// 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
go test ./internal/... -v -cover
Expected output:
=== 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:
- Interface decoupling: Repository and Service both interact through interfaces, facilitating unit testing (mocking) and future storage replacement
- Pointer updates:
UpdateArticleRequestuses pointer fields to distinguish zero values from unset values - Unified errors:
AppErrorencapsulates error codes and messages for unified frontend handling - Concurrency safety:
sync.RWMutexprotects 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:
- Requirements analysis: Defined functional boundaries and non-functional requirements
- Directory structure: Adopted the
cmd/internal/pkgstandard layout - Data models: Defined User, Category, Article, and request/response structs
- Repository layer: Designed interfaces + in-memory implementation, ensuring concurrency safety
- Service layer: Implemented business logic, parameter validation, error handling
- Unified error codes:
AppErrorencapsulates error information for unified frontend handling - Unit tests: Repository and Service layers each have tests covering normal and error scenarios
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:
- Create tag
POST /tags - Query tag list
GET /tags - Count the number of articles associated with each tag
Exercise 2: Implement Article Search Functionality
Add a Search method to ArticleService:
- Fuzzy search by title keyword
- Filter by category ID
- Filter by tag
- Support pagination
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:
- All articles are created successfully
- IDs have no duplicates
- No data races (use
go test -raceto detect)
Next Lesson: Comprehensive Project (Part 2) — HTTP Routing, Middleware, and Complete API



