NestJS (модули, сервисы) → ручная чистая архитектура
@Injectable() → интерфейсы + конструкторы
PrismaService → Repository интерфейс
DTO в NestJS → отдельные структуры запросов/ответов
Валидация через class-validator → ручная или go-playground/validator
mkdir go-clean-arch && cd go-clean-arch
go mod init go-clean-arch
go get github.com/google/uuid
go mod tidy
# Создаём структуру
mkdir -p internal/domain
mkdir -p internal/repository
mkdir -p internal/service
mkdir -p internal/transport/http
mkdir -p cmd/server
go-clean-arch/ ├── cmd/ │ └── server/ │ └── main.go # Точка входа, DI ├── internal/ │ ├── domain/ # Бизнес-сущности (НЕ зависят ни от чего) │ │ ├── user.go # User, UserRepository (интерфейс!) │ │ └── errors.go # Бизнес-ошибки │ ├── repository/ # Реализация доступа к данным │ │ └── postgres/ # postgres.UserRepo (implements domain.UserRepository) │ │ └── user_repo.go │ ├── service/ # Бизнес-логика (Use Cases) │ │ └── user_service.go # UserService │ └── transport/ # Транспортный слой (HTTP, gRPC) │ └── http/ │ ├── handler.go # HTTP-хендлеры │ ├── dto.go # DTO для запросов/ответов │ └── router.go # Настройка маршрутов └── go.mod
┌─────────────────────────────────────────────────────┐ │ cmd/server/main.go │ │ (композиция зависимостей) │ └────────┬──────────────────────────────┬─────────────┘ │ │ ┌────────▼──────────┐ ┌────────▼──────────┐ │ transport/http │ │ repository/ │ │ (handler, dto) │ │ postgres/ │ └────────┬──────────┘ └────────▲──────────┘ │ │ │ handler вызывает │ реализует │ service │ интерфейс │ │ ┌────────▼──────────────────────────────┴──────────┐ │ service/ │ │ (бизнес-логика) │ └────────┬─────────────────────────────────────────┘ │ │ service использует │ интерфейс репозитория │ ┌────────▼─────────────────────────────────────────┐ │ domain/ │ │ User (сущность) │ │ UserRepository (интерфейс) │ │ ErrNotFound, ErrValidation (ошибки) │ └──────────────────────────────────────────────────┘
ПРАВИЛО: Стрелки ВСЕГДА направлены ВНУТРЬ. domain НЕ импортирует ничего внешнего. service импортирует domain. repository/postgres импортирует domain. transport/http импортирует domain + service.
internal/domain/user.go// Пакет domain содержит ЧИСТЫЕ бизнес-сущности.
// Никаких зависимостей от БД, HTTP, фреймворков!
package domain
import (
"context"
"time"
)
// User — доменная модель. Никаких тегов json/bson!
// Это чистая бизнес-сущность, не привязанная к транспорту или БД.
type User struct {
ID string
Email string
Name string
Password string // ХЕШ пароля, никогда не plaintext
Role string
Active bool
CreatedAt time.Time
UpdatedAt time.Time
}
// UserRepository — ИНТЕРФЕЙС в доменном слое.
// Определяет КОНТРАКТ доступа к данным.
// Реализация будет в repository/postgres (или mocks для тестов).
// Это ПРАВИЛО ИНВЕРСИИ ЗАВИСИМОСТЕЙ (D из SOLID).
type UserRepository interface {
Create(ctx context.Context, user *User) error
GetByID(ctx context.Context, id string) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
List(ctx context.Context, offset, limit int) ([]User, error)
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
}
// CreateUserParams — параметры для создания пользователя
type CreateUserParams struct {
Email string
Name string
Password string // plaintext, будет захеширован в сервисе
Role string
}
// UpdateUserParams — параметры для обновления
type UpdateUserParams struct {
Name *string // Указатель: nil = не обновлять
Email *string
Password *string
Role *string
Active *bool
}
internal/domain/errors.gopackage domain
import "errors"
// Бизнес-ошибки уровня домена.
// Не зависят от HTTP/gRPC статусов.
var (
ErrNotFound = errors.New("resource not found")
ErrAlreadyExists = errors.New("resource already exists")
ErrInvalidInput = errors.New("invalid input")
ErrUnauthorized = errors.New("unauthorized")
ErrInternalServer = errors.New("internal server error")
)
// ValidationError — ошибка валидации с деталями
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return "validation: " + e.Field + " — " + e.Message
}
internal/repository/postgres/user_repo.gopackage postgres
import (
"context"
"fmt"
"sync"
"time"
"go-clean-arch/internal/domain"
"github.com/google/uuid"
)
// UserRepo — РЕАЛИЗАЦИЯ интерфейса domain.UserRepository.
// В реальном проекте здесь был бы pgxpool.
// Сейчас — in-memory для демонстрации.
type UserRepo struct {
mu sync.RWMutex
users map[string]*domain.User
}
// NewUserRepo — конструктор
func NewUserRepo() *UserRepo {
return &UserRepo{
users: make(map[string]*domain.User),
}
}
// Проверка на этапе компиляции: UserRepo реализует UserRepository
var _ domain.UserRepository = (*UserRepo)(nil)
func (r *UserRepo) Create(ctx context.Context, user *domain.User) error {
r.mu.Lock()
defer r.mu.Unlock()
// Проверяем уникальность email
for _, u := range r.users {
if u.Email == user.Email {
return fmt.Errorf("%w: email %q", domain.ErrAlreadyExists, user.Email)
}
}
if user.ID == "" {
user.ID = uuid.New().String()
}
user.CreatedAt = time.Now()
user.UpdatedAt = time.Now()
r.users[user.ID] = user
return nil
}
func (r *UserRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
user, ok := r.users[id]
if !ok {
return nil, fmt.Errorf("user %q: %w", id, domain.ErrNotFound)
}
return user, nil
}
func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, u := range r.users {
if u.Email == email {
return u, nil
}
}
return nil, fmt.Errorf("email %q: %w", email, domain.ErrNotFound)
}
func (r *UserRepo) List(ctx context.Context, offset, limit int) ([]domain.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
var result []domain.User
for _, u := range r.users {
result = append(result, *u)
}
// Простая пагинация
if offset >= len(result) {
return []domain.User{}, nil
}
end := offset + limit
if end > len(result) {
end = len(result)
}
return result[offset:end], nil
}
func (r *UserRepo) Update(ctx context.Context, user *domain.User) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.users[user.ID]; !ok {
return fmt.Errorf("user %q: %w", user.ID, domain.ErrNotFound)
}
user.UpdatedAt = time.Now()
r.users[user.ID] = user
return nil
}
func (r *UserRepo) Delete(ctx context.Context, id string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.users[id]; !ok {
return fmt.Errorf("user %q: %w", id, domain.ErrNotFound)
}
delete(r.users, id)
return nil
}
internal/service/user_service.gopackage service
import (
"context"
"fmt"
"strings"
"go-clean-arch/internal/domain"
)
// UserService — СЕРВИСНЫЙ СЛОЙ.
// Содержит бизнес-логику (use cases).
// Зависит ТОЛЬКО от domain (интерфейс репозитория + модели).
type UserService struct {
repo domain.UserRepository
}
// NewUserService — конструктор
func NewUserService(repo domain.UserRepository) *UserService {
return &UserService{repo: repo}
}
// Register — регистрация нового пользователя
func (s *UserService) Register(ctx context.Context, params domain.CreateUserParams) (*domain.User, error) {
// ВАЛИДАЦИЯ (бизнес-правила)
if strings.TrimSpace(params.Email) == "" {
return nil, &domain.ValidationError{Field: "email", Message: "must not be empty"}
}
if !strings.Contains(params.Email, "@") {
return nil, &domain.ValidationError{Field: "email", Message: "invalid format"}
}
if len(params.Password) < 8 {
return nil, &domain.ValidationError{Field: "password", Message: "must be at least 8 characters"}
}
if params.Name == "" {
return nil, &domain.ValidationError{Field: "name", Message: "must not be empty"}
}
// Проверяем, не занят ли email
existing, err := s.repo.GetByEmail(ctx, params.Email)
if err == nil && existing != nil {
return nil, fmt.Errorf("%w: email %q already registered", domain.ErrAlreadyExists, params.Email)
}
// ХЕШИРОВАНИЕ пароля (в реальности — bcrypt/argon2)
hashedPassword := hashPassword(params.Password)
user := &domain.User{
Email: params.Email,
Name: params.Name,
Password: hashedPassword,
Role: params.Role,
Active: true,
}
if user.Role == "" {
user.Role = "user" // Роль по умолчанию
}
if err := s.repo.Create(ctx, user); err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
return user, nil
}
// GetUser — получение пользователя
func (s *UserService) GetUser(ctx context.Context, id string) (*domain.User, error) {
if id == "" {
return nil, &domain.ValidationError{Field: "id", Message: "must not be empty"}
}
return s.repo.GetByID(ctx, id)
}
// ListUsers — список пользователей
func (s *UserService) ListUsers(ctx context.Context, offset, limit int) ([]domain.User, int, error) {
if limit <= 0 || limit > 100 {
limit = 10
}
if offset < 0 {
offset = 0
}
users, err := s.repo.List(ctx, offset, limit)
if err != nil {
return nil, 0, err
}
// TODO: подсчёт общего количества через repo.Count()
return users, len(users), nil
}
// UpdateUser — обновление пользователя
func (s *UserService) UpdateUser(ctx context.Context, id string, params domain.UpdateUserParams) (*domain.User, error) {
user, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if params.Name != nil {
user.Name = *params.Name
}
if params.Email != nil {
// Проверяем уникальность нового email
if *params.Email != user.Email {
existing, _ := s.repo.GetByEmail(ctx, *params.Email)
if existing != nil {
return nil, fmt.Errorf("%w: email %q", domain.ErrAlreadyExists, *params.Email)
}
}
user.Email = *params.Email
}
if params.Password != nil {
user.Password = hashPassword(*params.Password)
}
if params.Role != nil {
user.Role = *params.Role
}
if params.Active != nil {
user.Active = *params.Active
}
if err := s.repo.Update(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// DeleteUser — удаление пользователя
func (s *UserService) DeleteUser(ctx context.Context, id string) error {
return s.repo.Delete(ctx, id)
}
// hashPassword — заглушка (в реальности — bcrypt.GenerateFromPassword)
func hashPassword(pass string) string {
return "hashed_" + pass
}
internal/transport/http/dto.gopackage http
import (
"go-clean-arch/internal/domain"
"time"
)
// ╔══════════════════════════════════════════════════════════╗
// ║ DTO — Data Transfer Objects ║
// ║ Отделяем транспортный слой от доменного! ║
// ╚══════════════════════════════════════════════════════════╝
// --- Запросы (от клиента) ---
type CreateUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
Password string `json:"password"`
Role string `json:"role,omitempty"`
}
// ToDomain — конвертирует DTO в доменные параметры
func (r *CreateUserRequest) ToDomain() domain.CreateUserParams {
return domain.CreateUserParams{
Email: r.Email,
Name: r.Name,
Password: r.Password,
Role: r.Role,
}
}
type UpdateUserRequest struct {
Name *string `json:"name,omitempty"`
Email *string `json:"email,omitempty"`
Password *string `json:"password,omitempty"`
Role *string `json:"role,omitempty"`
Active *bool `json:"active,omitempty"`
}
func (r *UpdateUserRequest) ToDomain() domain.UpdateUserParams {
return domain.UpdateUserParams{
Name: r.Name,
Email: r.Email,
Password: r.Password,
Role: r.Role,
Active: r.Active,
}
}
// --- Ответы (клиенту) ---
type UserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// ВНИМАНИЕ: Password НЕ передаётся в ответе!
}
// FromDomain — конвертирует доменную модель в DTO ответа
func UserResponseFromDomain(u *domain.User) *UserResponse {
return &UserResponse{
ID: u.ID,
Email: u.Email,
Name: u.Name,
Role: u.Role,
Active: u.Active,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
type ListUsersResponse struct {
Users []UserResponse `json:"users"`
Total int `json:"total"`
}
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
type ValidationErrorResponse struct {
Error string `json:"error"`
Details []string `json:"details"`
}
internal/transport/http/handler.gopackage http
import (
"encoding/json"
"errors"
"log"
"net/http"
"go-clean-arch/internal/domain"
"go-clean-arch/internal/service"
)
// UserHandler — HTTP-обработчики для пользователей.
// Зависит от сервиса, НЕ от репозитория напрямую.
type UserHandler struct {
svc *service.UserService
}
func NewUserHandler(svc *service.UserService) *UserHandler {
return &UserHandler{svc: svc}
}
// CreateUser — POST /api/users
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
// Конвертируем DTO → доменные параметры
params := req.ToDomain()
// Вызываем СЕРВИС (вся бизнес-логика там)
user, err := h.svc.Register(r.Context(), params)
if err != nil {
handleServiceError(w, err)
return
}
// Конвертируем доменную модель → DTO ответа
resp := UserResponseFromDomain(user)
writeJSON(w, http.StatusCreated, resp)
}
// GetUser — GET /api/users/{id}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
user, err := h.svc.GetUser(r.Context(), id)
if err != nil {
handleServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, UserResponseFromDomain(user))
}
// ListUsers — GET /api/users
func (h *UserHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
offset, limit := 0, 10
// TODO: парсить из query-параметров
users, total, err := h.svc.ListUsers(r.Context(), offset, limit)
if err != nil {
handleServiceError(w, err)
return
}
resp := ListUsersResponse{Total: total}
for _, u := range users {
resp.Users = append(resp.Users, *UserResponseFromDomain(&u))
}
writeJSON(w, http.StatusOK, resp)
}
// UpdateUser — PUT /api/users/{id}
func (h *UserHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req UpdateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
params := req.ToDomain()
user, err := h.svc.UpdateUser(r.Context(), id, params)
if err != nil {
handleServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, UserResponseFromDomain(user))
}
// DeleteUser — DELETE /api/users/{id}
func (h *UserHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := h.svc.DeleteUser(r.Context(), id); err != nil {
handleServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ╔══════════════════════════════════════════════════════════╗
// ║ Хелперы ║
// ╚══════════════════════════════════════════════════════════╝
// handleServiceError — маппит бизнес-ошибки на HTTP-статусы
func handleServiceError(w http.ResponseWriter, err error) {
var valErr *domain.ValidationError
switch {
case errors.Is(err, domain.ErrNotFound):
writeError(w, http.StatusNotFound, err.Error())
case errors.Is(err, domain.ErrAlreadyExists):
writeError(w, http.StatusConflict, err.Error())
case errors.Is(err, domain.ErrInvalidInput):
writeError(w, http.StatusBadRequest, err.Error())
case errors.As(err, &valErr):
writeJSON(w, http.StatusUnprocessableEntity, ValidationErrorResponse{
Error: "validation_error",
Details: []string{valErr.Error()},
})
case errors.Is(err, domain.ErrUnauthorized):
writeError(w, http.StatusUnauthorized, err.Error())
default:
log.Printf("Неизвестная ошибка: %v", err)
writeError(w, http.StatusInternalServerError, "Internal server error")
}
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, ErrorResponse{
Error: http.StatusText(status),
Message: msg,
})
}
internal/transport/http/router.gopackage http
import (
"net/http"
)
func SetupRouter(h *UserHandler) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/users", h.ListUsers)
mux.HandleFunc("POST /api/users", h.CreateUser)
mux.HandleFunc("GET /api/users/{id}", h.GetUser)
mux.HandleFunc("PUT /api/users/{id}", h.UpdateUser)
mux.HandleFunc("DELETE /api/users/{id}", h.DeleteUser)
return mux
}
cmd/server/main.gopackage main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"go-clean-arch/internal/repository/postgres"
"go-clean-arch/internal/service"
transporthttp "go-clean-arch/internal/transport/http"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
log.Println("🏗️ Чистая архитектура — запуск сервера")
// ╔══════════════════════════════════════════════════════╗
// ║ DI — Dependency Injection (ручная композиция) ║
// ╚══════════════════════════════════════════════════════╝
// 1. Создаём репозиторий (внутренний слой)
repo := postgres.NewUserRepo()
// 2. Создаём сервис (зависит от интерфейса репозитория)
svc := service.NewUserService(repo)
// 3. Создаём обработчики (зависят от сервиса)
handler := transporthttp.NewUserHandler(svc)
// 4. Настраиваем роутер
router := transporthttp.SetupRouter(handler)
// 5. Запускаем HTTP-сервер
server := &http.Server{
Addr: ":8080",
Handler: router,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
log.Println("✅ Сервер на http://localhost:8080")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Сервер: %v", err)
}
}()
// Graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("🛑 Выключение...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server.Shutdown(ctx)
log.Println("✅ Остановлен")
}
# Запуск сервера
go run ./cmd/server/main.go
# Тестирование API
# Создание пользователя
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","name":"Alice","password":"securepass123"}'
# Получение пользователя (подставьте ID)
curl http://localhost:8080/api/users/ВАШ_ID
# Список пользователей
curl http://localhost:8080/api/users
# Обновление
curl -X PUT http://localhost:8080/api/users/ВАШ_ID \
-H "Content-Type: application/json" \
-d '{"name":"Alice Updated"}'
# Удаление
curl -X DELETE http://localhost:8080/api/users/ВАШ_ID
| Слой | Директория | Зависит от | Содержит |
|---|---|---|---|
| Domain | internal/domain | НИ ОТ ЧЕГО (чистый Go) | Сущности, интерфейсы, бизнес-ошибки |
| Repository | internal/repository/* | domain | Реализация доступа к данным |
| Service | internal/service | domain | Бизнес-логика, use cases |
| Transport | internal/transport/* | domain, service | HTTP/gRPC хендлеры, DTO |
| Main | cmd/server | Все слои | DI, конфигурация, запуск |
💡 Best practices от сеньоров:
💡 Для Node.js разработчика:
@Injectable() — все зависимости передаются через конструкторы явно.