Handlerlarni modullarga ajratish (clean architecture)
CRUD loyihasida butun kod bitta main.go faylida edi. Handler JSON’ni o‘qiydi, ma’lumotni tekshiradi, map’ga yozadi va javob qaytaradi. 300 qatorlik loyiha uchun bu normal holat.
Endi tasavvur qiling: loyihada 50 ta endpoint bor, ma’lumotlar PostgreSQL’da saqlanadi, kitob qo‘shilganda email yuboriladi va jamoada besh kishi ishlaydi. Hammasi bitta faylda yoki bir-biriga chalkashib ketgan funksiyalarda bo‘lsa, quyidagi muammolar paydo bo‘ladi:
- database’ni almashtirish uchun barcha handlerlarni qayta yozish kerak bo‘ladi;
- biznes qoidasini test qilish uchun HTTP server va database ishga tushirilishi kerak;
- bitta joydagi o‘zgarish kutilmagan joyni buzadi.
Clean architecture — kodni vazifasiga qarab qatlamlarga ajratish yondashuvi. Uning asosiy g‘oyasi: har bir qatlam faqat o‘z ishini bajaradi va boshqa qatlamlarning ichki tuzilishini bilmaydi.
Uchta qatlam
Go API loyihalarida ko‘pincha uch qatlamli tuzilma ishlatiladi:
HTTP so‘rov
↓
┌─────────────┐
│ Handler │ HTTP'ni tushunadi: JSON o‘qiydi, status kod qaytaradi
└──────┬──────┘
↓
┌─────────────┐
│ Service │ Biznes qoidalari: tekshiruvlar, hisob-kitoblar
└──────┬──────┘
↓
┌─────────────┐
│ Repository │ Ma’lumotni saqlash va o‘qish: SQL, map, fayl
└─────────────┘
| Qatlam |
Nimani biladi |
Nimani bilmaydi |
| Handler |
HTTP, JSON, status kodlar |
SQL, biznes qoidalari |
| Service |
Biznes qoidalari |
HTTP, SQL |
| Repository |
Ma’lumotni qanday saqlash |
HTTP, biznes qoidalari |
Restoran misoliga qaytamiz. Ofitsiant (handler) buyurtmani qabul qiladi va taomni olib keladi, lekin ovqat pishirmaydi. Oshpaz (service) retsept bo‘yicha taom tayyorlaydi, lekin mijoz bilan gaplashmaydi. Omborchi (repository) mahsulotlarni saqlaydi va beradi, lekin ular nimaga ishlatilishini bilmaydi. Ofitsiant almashsa, oshxona ishlashda davom etadi.
Bog‘liqliklar yo‘nalishi
Eng muhim qoida: bog‘liqlik faqat ichkariga yo‘naladi. Handler service’ni chaqiradi, service repository’ni chaqiradi. Teskari yo‘nalishda hech qanday murojaat bo‘lmaydi.
Bundan tashqari, service aniq bir repository’ga emas, interfeysga bog‘lanadi. Service faqat "menga kitobni ID bo‘yicha topadigan narsa kerak" deydi. Bu map’mi, PostgreSQL’mi yoki test uchun soxta obyektmi — service uchun farqi yo‘q.
Loyiha tuzilmasi
books-api/
├── cmd/
│ └── api/
│ └── main.go # hammasini yig‘ib, serverni ishga tushiradi
├── internal/
│ ├── book/
│ │ ├── model.go # Book turi va domen xatolari
│ │ ├── service.go # biznes qoidalari
│ │ └── service_test.go
│ ├── storage/
│ │ └── memory.go # repository: xotiradagi saqlash
│ └── httpapi/
│ ├── handler.go # HTTP handlerlar
│ └── response.go # JSON yordamchilari
└── go.mod
cmd/api/ — dasturning kirish nuqtasi. Bir loyihada bir nechta dastur bo‘lishi mumkin: cmd/api, cmd/migrate, cmd/worker.
internal/ — Go’ning maxsus katalogi. Uning ichidagi paketlarni faqat shu modul import qila oladi. Tashqi loyihalar ularga murojaat qila olmaydi.
- Paketlar texnik qatlam bo‘yicha emas, mavzu bo‘yicha ham ajratilishi mumkin:
book, user, order. Bu darsda book paketi domen modeli va service’ni saqlaydi.
Info
Go’da loyiha tuzilmasining yagona rasmiy standarti yo‘q. Kichik loyihada ortiqcha kataloglar ochish ham kodni murakkablashtiradi. Tuzilma loyiha bilan birga o‘sishi kerak. Bu darsdagi tuzilma o‘rta hajmdagi API uchun yaxshi boshlang‘ich nuqta.
Loyihani yaratamiz:
mkdir -p books-api/cmd/api books-api/internal/{book,storage,httpapi}
cd books-api
go mod init books-api
1. Model va domen xatolari
| internal/book/model.go |
|---|
| package book
import (
"errors"
"time"
)
type Book struct {
ID int `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Year int `json:"year"`
CreatedAt time.Time `json:"created_at"`
}
type Input struct {
Title string `json:"title"`
Author string `json:"author"`
Year int `json:"year"`
}
var (
ErrNotFound = errors.New("kitob topilmadi")
ErrDuplicate = errors.New("bu kitob allaqachon mavjud")
)
// ValidationError — foydalanuvchi kiritgan ma’lumotdagi xato.
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Message
}
|
Domen xatolari (ErrNotFound, ErrDuplicate) HTTP haqida hech narsa bilmaydi. Ularda status kod yo‘q. Qaysi xato qaysi status kodga mos kelishini handler qatlami hal qiladi.
2. Service — biznes qoidalari
| internal/book/service.go |
|---|
| package book
import (
"context"
"strings"
"time"
)
// Repository — service'ga kerakli saqlash amallari.
type Repository interface {
List(ctx context.Context) ([]Book, error)
Get(ctx context.Context, id int) (Book, error)
Create(ctx context.Context, b Book) (Book, error)
ExistsByTitle(ctx context.Context, title, author string) (bool, error)
}
type Service struct {
repo Repository
now func() time.Time
}
func NewService(repo Repository) *Service {
return &Service{repo: repo, now: time.Now}
}
func (s *Service) List(ctx context.Context) ([]Book, error) {
return s.repo.List(ctx)
}
func (s *Service) Get(ctx context.Context, id int) (Book, error) {
return s.repo.Get(ctx, id)
}
func (s *Service) Create(ctx context.Context, in Input) (Book, error) {
in.Title = strings.TrimSpace(in.Title)
in.Author = strings.TrimSpace(in.Author)
if in.Title == "" {
return Book{}, &ValidationError{"title", "majburiy maydon"}
}
if in.Author == "" {
return Book{}, &ValidationError{"author", "majburiy maydon"}
}
if in.Year < 1 || in.Year > s.now().Year() {
return Book{}, &ValidationError{"year", "noto‘g‘ri yil"}
}
exists, err := s.repo.ExistsByTitle(ctx, in.Title, in.Author)
if err != nil {
return Book{}, err
}
if exists {
return Book{}, ErrDuplicate
}
return s.repo.Create(ctx, Book{
Title: in.Title,
Author: in.Author,
Year: in.Year,
CreatedAt: s.now().UTC(),
})
}
|
Bu yerda bir nechta muhim qarorlar bor:
- Interfeys iste’molchi tomonida e’lon qilingan.
Repository interfeysi repository paketida emas, uni ishlatadigan book paketida turibdi. Go’da bu keng tarqalgan qoida: interfeysni u kerak bo‘lgan joyda, faqat kerakli metodlar bilan e’lon qiling.
- Biznes qoidalari service’da. "Bir muallifning bir xil nomli ikki kitobi bo‘lmasligi kerak" degan qoida HTTP’ga ham, SQL’ga ham tegishli emas. Bu ilovaning o‘z qoidasi.
- Vaqt ham bog‘liqlik.
now maydoni testda vaqtni boshqarish imkonini beradi. time.Now() to‘g‘ridan-to‘g‘ri chaqirilsa, "joriy yildan katta yil" tekshiruvini barqaror test qilish qiyin bo‘ladi.
- Har bir metod birinchi parametr sifatida
context.Context oladi. U so‘rovdan database’gacha uzatiladi.
3. Repository — ma’lumotni saqlash
Hozircha ma’lumotlar xotirada saqlanadi. Keyingi bo‘limda xuddi shu interfeys uchun PostgreSQL repository yoziladi va service kodi o‘zgarmaydi.
| internal/storage/memory.go |
|---|
| package storage
import (
"context"
"slices"
"strings"
"sync"
"books-api/internal/book"
)
type MemoryBookRepo struct {
mu sync.RWMutex
books map[int]book.Book
nextID int
}
func NewMemoryBookRepo() *MemoryBookRepo {
return &MemoryBookRepo{books: make(map[int]book.Book), nextID: 1}
}
func (r *MemoryBookRepo) List(ctx context.Context) ([]book.Book, error) {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]book.Book, 0, len(r.books))
for _, b := range r.books {
out = append(out, b)
}
slices.SortFunc(out, func(a, b book.Book) int { return a.ID - b.ID })
return out, nil
}
func (r *MemoryBookRepo) Get(ctx context.Context, id int) (book.Book, error) {
r.mu.RLock()
defer r.mu.RUnlock()
b, ok := r.books[id]
if !ok {
return book.Book{}, book.ErrNotFound
}
return b, nil
}
func (r *MemoryBookRepo) Create(ctx context.Context, b book.Book) (book.Book, error) {
r.mu.Lock()
defer r.mu.Unlock()
b.ID = r.nextID
r.nextID++
r.books[b.ID] = b
return b, nil
}
func (r *MemoryBookRepo) ExistsByTitle(ctx context.Context, title, author string) (bool, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, b := range r.books {
if strings.EqualFold(b.Title, title) && strings.EqualFold(b.Author, author) {
return true, nil
}
}
return false, nil
}
|
Repository hech qanday tekshiruv qilmaydi. U faqat saqlaydi va qaytaradi. MemoryBookRepoda book.Repository interfeysining barcha metodlari bor. Go’da interfeysni "implements" deb e’lon qilish shart emas, metodlar mos kelsa kifoya.
4. Handler — HTTP qatlami
Avval JSON yordamchilari:
| internal/httpapi/response.go |
|---|
| package httpapi
import (
"encoding/json"
"log/slog"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("JSON yozishda xato", "err", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
|
Handler’ning o‘zi:
| internal/httpapi/handler.go |
|---|
| package httpapi
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"books-api/internal/book"
)
// BookService — handler'ga kerakli biznes amallari.
type BookService interface {
List(ctx context.Context) ([]book.Book, error)
Get(ctx context.Context, id int) (book.Book, error)
Create(ctx context.Context, in book.Input) (book.Book, error)
}
type Handler struct {
books BookService
}
func NewHandler(books BookService) *Handler {
return &Handler{books: books}
}
func (h *Handler) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /books", h.listBooks)
mux.HandleFunc("GET /books/{id}", h.getBook)
mux.HandleFunc("POST /books", h.createBook)
return mux
}
func (h *Handler) listBooks(w http.ResponseWriter, r *http.Request) {
books, err := h.books.List(r.Context())
if err != nil {
h.handleError(w, r, err)
return
}
writeJSON(w, http.StatusOK, books)
}
func (h *Handler) getBook(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "id son bo‘lishi kerak")
return
}
b, err := h.books.Get(r.Context(), id)
if err != nil {
h.handleError(w, r, err)
return
}
writeJSON(w, http.StatusOK, b)
}
func (h *Handler) createBook(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var in book.Input
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "JSON noto‘g‘ri")
return
}
b, err := h.books.Create(r.Context(), in)
if err != nil {
h.handleError(w, r, err)
return
}
writeJSON(w, http.StatusCreated, b)
}
// handleError domen xatolarini HTTP status kodlariga aylantiradi.
func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error) {
var vErr *book.ValidationError
switch {
case errors.As(err, &vErr):
writeError(w, http.StatusBadRequest, vErr.Error())
case errors.Is(err, book.ErrNotFound):
writeError(w, http.StatusNotFound, err.Error())
case errors.Is(err, book.ErrDuplicate):
writeError(w, http.StatusConflict, err.Error())
default:
slog.Error("ichki xato", "err", err, "path", r.URL.Path)
writeError(w, http.StatusInternalServerError, "ichki server xatosi")
}
}
|
Handler juda "yupqa" bo‘lib qoldi. U JSON’ni o‘qiydi, service’ni chaqiradi va natijani HTTP javobiga aylantiradi. Domen xatolarini status kodga moslashtirish bitta handleError funksiyasida jamlangan.
5. Hammasini yig‘ish: main.go
| cmd/api/main.go |
|---|
| package main
import (
"log"
"net/http"
"time"
"books-api/internal/book"
"books-api/internal/httpapi"
"books-api/internal/storage"
)
func main() {
repo := storage.NewMemoryBookRepo()
service := book.NewService(repo)
handler := httpapi.NewHandler(service)
srv := &http.Server{
Addr: ":8080",
Handler: handler.Routes(),
ReadHeaderTimeout: 5 * time.Second,
}
log.Println("Server: http://localhost:8080")
log.Fatal(srv.ListenAndServe())
}
|
main funksiyasi — obyektlar yaratiladigan va bir-biriga ulanadigan yagona joy. Bu usul dependency injection (bog‘liqliklarni tashqaridan berish) deyiladi. Service o‘z repository’sini o‘zi yaratmaydi, uni tashqaridan oladi. Shuning uchun repository’ni almashtirish uchun faqat main.godagi bitta qatorni o‘zgartirish yetarli:
repo := storage.NewPostgresBookRepo(db) // service va handler o‘zgarmaydi
Ishga tushiramiz:
curl -X POST http://localhost:8080/books -d '{"title":"Sarob","author":"Abdulla Qahhor","year":1937}'
curl -X POST http://localhost:8080/books -d '{"title":"sarob","author":"abdulla qahhor","year":1937}'
curl -X POST http://localhost:8080/books -d '{"title":"Ufq","author":"Said Ahmad","year":3000}'
{"id":1,"title":"Sarob","author":"Abdulla Qahhor","year":1937,"created_at":"2026-09-30T10:00:00Z"}
{"error":"bu kitob allaqachon mavjud"}
{"error":"year: noto‘g‘ri yil"}
6. Service’ni test qilish
Qatlamlarga ajratishning eng katta foydasi testlarda ko‘rinadi. Service’ni test qilish uchun HTTP server ham, database ham kerak emas:
| internal/book/service_test.go |
|---|
| package book
import (
"context"
"errors"
"testing"
"time"
)
// fakeRepo — test uchun soddalashtirilgan repository.
type fakeRepo struct {
exists bool
created []Book
}
func (f *fakeRepo) List(ctx context.Context) ([]Book, error) { return nil, nil }
func (f *fakeRepo) Get(ctx context.Context, id int) (Book, error) { return Book{}, ErrNotFound }
func (f *fakeRepo) ExistsByTitle(ctx context.Context, t, a string) (bool, error) {
return f.exists, nil
}
func (f *fakeRepo) Create(ctx context.Context, b Book) (Book, error) {
b.ID = len(f.created) + 1
f.created = append(f.created, b)
return b, nil
}
func newTestService(repo Repository) *Service {
s := NewService(repo)
s.now = func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }
return s
}
func TestCreate(t *testing.T) {
ctx := context.Background()
t.Run("to‘g‘ri kitob saqlanadi", func(t *testing.T) {
repo := &fakeRepo{}
b, err := newTestService(repo).Create(ctx, Input{Title: " Sarob ", Author: "Qahhor", Year: 1937})
if err != nil {
t.Fatalf("kutilmagan xato: %v", err)
}
if b.Title != "Sarob" {
t.Errorf("title = %q, bo‘shliqlar olib tashlanishi kerak edi", b.Title)
}
if len(repo.created) != 1 {
t.Errorf("repository'ga %d ta yozuv tushdi, 1 ta kutilgan", len(repo.created))
}
})
t.Run("kelajakdagi yil rad etiladi", func(t *testing.T) {
_, err := newTestService(&fakeRepo{}).Create(ctx, Input{Title: "A", Author: "B", Year: 2027})
var vErr *ValidationError
if !errors.As(err, &vErr) || vErr.Field != "year" {
t.Errorf("year uchun ValidationError kutilgan, olindi: %v", err)
}
})
t.Run("dublikat rad etiladi", func(t *testing.T) {
_, err := newTestService(&fakeRepo{exists: true}).Create(ctx, Input{Title: "A", Author: "B", Year: 2000})
if !errors.Is(err, ErrDuplicate) {
t.Errorf("ErrDuplicate kutilgan, olindi: %v", err)
}
})
}
|
go test ./internal/book/ -v
=== RUN TestCreate
=== RUN TestCreate/to‘g‘ri_kitob_saqlanadi
=== RUN TestCreate/kelajakdagi_yil_rad_etiladi
=== RUN TestCreate/dublikat_rad_etiladi
--- PASS: TestCreate (0.00s)
--- PASS: TestCreate/to‘g‘ri_kitob_saqlanadi (0.00s)
--- PASS: TestCreate/kelajakdagi_yil_rad_etiladi (0.00s)
--- PASS: TestCreate/dublikat_rad_etiladi (0.00s)
PASS
ok books-api/internal/book 0.002s
Testlar millisekundlarda ishlaydi va tashqi tizimlarga bog‘liq emas. Vaqt ham qat’iy belgilangan, shuning uchun test 2027-yilda ham bir xil natija beradi.
Handler’ni esa soxta BookService va net/http/httptest paketi yordamida xuddi shunday alohida test qilish mumkin. Test yozish haqida batafsil Go’da test yozish darsida.
Qachon qatlamlarga ajratmaslik kerak?
Har bir loyiha uchun uch qatlam shart emas. Ikki-uchta endpointli kichik servis yoki bir martalik skriptda qatlamlar foyda keltirmaydi, faqat kodni ko‘paytiradi. Quyidagi belgilar paydo bo‘lganda ajratish haqida o‘ylang:
- handlerlarda biznes qoidalari ko‘paydi;
- bir xil mantiq bir nechta handlerda takrorlanmoqda;
- testlar yozish uchun database ishga tushirishga majbursiz;
- saqlash usulini almashtirish rejalashtirilmoqda.
Xulosa
- Handler HTTP bilan, service biznes qoidalari bilan, repository ma’lumotni saqlash bilan shug‘ullanadi.
- Bog‘liqlik ichkariga yo‘naladi va interfeyslar orqali quriladi.
- Interfeys uni ishlatadigan paketda, faqat kerakli metodlar bilan e’lon qilinadi.
- Domen xatolari HTTP’dan mustaqil. Ularni status kodga aylantirish handler vazifasi.
- Barcha obyektlar
mainda yaratilib, bir-biriga ulanadi.
- Service’ni database va serversiz, tez test qilish mumkin.
Keyingi bo‘limda JSON bilan ishlashni chuqurroq o‘rganamiz: so‘rov tanasini xavfsiz o‘qish, javoblarni formatlash va validatsiya.