О проекте
Цель: закрепить навыки разработки в полноценном сервисе.
Проект: Сервис управления репозиториями (Git Manager)
Длительность: 8 недель
Описание проекта
Вы создадите полноценный REST API сервис для управления репозиториями кода. Проект включает:
- Clean Architecture с разделением слоёв
- PostgreSQL для хранения данных
- Наблюдаемость (логи, метрики)
- Фоновая обработка (outbox pattern)
- Безопасность (аутентификация, авторизация)
- Контейнеризация через Docker
Требования к проекту
Обязательные компоненты
-
REST API
- POST /api/v1/repos — создание репозитория
- GET /api/v1/repos — список репозиториев
- GET /api/v1/repos/{id} — получение репозитория
- PUT /api/v1/repos/{id} — обновление репозитория
- DELETE /api/v1/repos/{id} — удаление репозитория
- POST /api/v1/repos/{id}/webhooks — регистрация webhook
-
Архитектура
- Clean Architecture с разделением на слои
- Domain models, services, repositories
- DTO для API контрактов
- Валидация входных данных
-
База данных
- PostgreSQL для хранения
- Миграции через goose
- Транзакции для атомарных операций
-
Наблюдаемость
- Structured logging через slog
- Prometheus метрики
- Health checks (/healthz, /readyz)
-
Фоновая обработка
- Outbox pattern для webhook delivery
- Background worker для отправки webhook'ов
-
Безопасность
- JWT аутентификация
- Проверка владения ресурсом
- Валидация входных данных
- HMAC подпись для webhook'ов
-
Контейнеризация
- Multi-stage Dockerfile
- Docker Compose для локальной разработки
-
Тестирование
- Юнит-тесты для всех слоёв
- Интеграционные тесты с testcontainers
- Покрытие кода > 70%
Структура проекта
git-manager/
├── cmd/
│ └── app/
│ └── main.go
├── internal/
│ ├── models/
│ │ ├── repo.go
│ │ ├── webhook.go
│ │ └── outbox_event.go
│ ├── repository/
│ │ ├── repo_repository.go
│ │ ├── webhook_repository.go
│ │ └── outbox_repository.go
│ ├── service/
│ │ ├── repo_service.go
│ │ ├── webhook_service.go
│ │ └── outbox_service.go
│ ├── usecase/
│ │ ├── create_repo_usecase.go
│ │ └── create_webhook_usecase.go
│ ├── handlers/
│ │ └── http/
│ │ ├── handler.go
│ │ ├── dto.go
│ │ └── routes.go
│ ├── middleware/
│ │ ├── auth.go
│ │ ├── logging.go
│ │ └── metrics.go
│ ├── config/
│ │ └── config.go
│ ├── observability/
│ │ ├── logger.go
│ │ └── metrics.go
│ └── app/
│ └── app.go
├── migrations/
│ ├── 001_create_repos.sql
│ ├── 002_create_webhooks.sql
│ └── 003_create_outbox.sql
├── docker/
│ ├── Dockerfile
│ └── docker-compose.yml
├── go.mod
├── go.sum
├── Makefile
└── README.md
Пошаговый план реализации
Неделя 1-2: Архитектура и базовая структура
День 1-2: Инициализация проекта
Создайте структуру проекта:
bashmkdir -p git-manager/{cmd/app,internal/{models,repository,service,usecase,handlers/http,middleware,config,observability,app},migrations,docker} cd git-manager go mod init github.com/yourname/git-manager
День 3-4: Domain models
Создайте модели:
go// internal/models/repo.go package models import "time" type Repo struct { ID string Name string Description string OwnerID string IsPrivate bool CreatedAt time.Time UpdatedAt time.Time } func NewRepo(name, description, ownerID string, isPrivate bool) *Repo { now := time.Now() return &Repo{ ID: generateID(), Name: name, Description: description, OwnerID: ownerID, IsPrivate: isPrivate, CreatedAt: now, UpdatedAt: now, } } func (r *Repo) Validate() error { if r.Name == "" { return errors.New("name is required") } if len(r.Name) < 3 || len(r.Name) > 100 { return errors.New("name must be 3-100 characters") } return nil } // internal/models/webhook.go package models type Webhook struct { ID string RepoID string URL string Events []string Secret string Active bool CreatedAt time.Time } // internal/models/outbox_event.go package models import "encoding/json" type OutboxEvent struct { ID string Type string Payload json.RawMessage Status string CreatedAt time.Time SentAt *time.Time }
День 5-7: Repository layer
Создайте интерфейсы и реализации:
go// internal/repository/repo_repository.go package repository import ( "context" "git-manager/internal/models" ) type RepoRepository interface { Create(ctx context.Context, repo *models.Repo) error GetByID(ctx context.Context, id string) (*models.Repo, error) List(ctx context.Context, ownerID string) ([]*models.Repo, error) Update(ctx context.Context, repo *models.Repo) error Delete(ctx context.Context, id string) error }
Реализуйте для PostgreSQL:
go// internal/repository/postgres_repo_repository.go package repository import ( "context" "database/sql" "git-manager/internal/models" ) type PostgresRepoRepository struct { db *sql.DB } func NewPostgresRepoRepository(db *sql.DB) *PostgresRepoRepository { return &PostgresRepoRepository{db: db} } func (r *PostgresRepoRepository) Create(ctx context.Context, repo *models.Repo) error { query := `INSERT INTO repos (id, name, description, owner_id, is_private, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)` _, err := r.db.ExecContext(ctx, query, repo.ID, repo.Name, repo.Description, repo.OwnerID, repo.IsPrivate, repo.CreatedAt, repo.UpdatedAt, ) return err } // Реализуйте остальные методы
День 8-10: Service layer
Создайте сервисы:
go// internal/service/repo_service.go package service import ( "context" "errors" "git-manager/internal/models" "git-manager/internal/repository" ) type RepoService struct { repo repository.RepoRepository } func NewRepoService(repo repository.RepoRepository) *RepoService { return &RepoService{repo: repo} } func (s *RepoService) CreateRepo(ctx context.Context, repo *models.Repo) error { if err := repo.Validate(); err != nil { return err } return s.repo.Create(ctx, repo) } func (s *RepoService) GetRepo(ctx context.Context, id string) (*models.Repo, error) { return s.repo.GetByID(ctx, id) } // Реализуйте остальные методы
Неделя 3-4: Use cases и handlers
День 1-3: Use cases
Создайте use cases:
go// internal/usecase/create_repo_usecase.go package usecase import ( "context" "git-manager/internal/models" "git-manager/internal/service" ) type CreateRepoUseCase struct { repoService *service.RepoService outboxService *service.OutboxService } func NewCreateRepoUseCase(repoSvc *service.RepoService, outboxSvc *service.OutboxService) *CreateRepoUseCase { return &CreateRepoUseCase{ repoService: repoSvc, outboxService: outboxSvc, } } func (uc *CreateRepoUseCase) Execute(ctx context.Context, repo *models.Repo) error { // Создаём репозиторий if err := uc.repoService.CreateRepo(ctx, repo); err != nil { return err } // Публикуем событие event := &models.OutboxEvent{ ID: generateID(), Type: "repo.created", Payload: marshalRepo(repo), Status: "pending", } return uc.outboxService.Publish(ctx, event) }
День 4-7: HTTP handlers
Создайте handlers:
go// internal/handlers/http/handler.go package http import ( "encoding/json" "net/http" "git-manager/internal/usecase" ) type Handler struct { createRepoUC *usecase.CreateRepoUseCase } func NewHandler(createRepoUC *usecase.CreateRepoUseCase) *Handler { return &Handler{createRepoUC: createRepoUC} } type CreateRepoRequest struct { Name string `json:"name" validate:"required,min=3,max=100"` Description string `json:"description"` IsPrivate bool `json:"is_private"` } type RepoResponse struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` OwnerID string `json:"owner_id"` IsPrivate bool `json:"is_private"` } func (h *Handler) CreateRepo(w http.ResponseWriter, r *http.Request) { var req CreateRepoRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } // Валидация // ... ownerID := r.Context().Value("user_id").(string) repo := models.NewRepo(req.Name, req.Description, ownerID, req.IsPrivate) if err := h.createRepoUC.Execute(r.Context(), repo); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(RepoResponse{ ID: repo.ID, Name: repo.Name, Description: repo.Description, OwnerID: repo.OwnerID, IsPrivate: repo.IsPrivate, }) } // Реализуйте остальные handlers
Неделя 5-6: Наблюдаемость и безопасность
День 1-3: Logging и metrics
Создайте observability:
go// internal/observability/logger.go package observability import ( "log/slog" "os" ) func NewLogger(level string) *slog.Logger { var lvl slog.Level switch level { case "debug": lvl = slog.LevelDebug case "info": lvl = slog.LevelInfo case "warn": lvl = slog.LevelWarn case "error": lvl = slog.LevelError } return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: lvl, })) }
go// internal/observability/metrics.go package observability import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) var ( HTTPRequestsTotal = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "http_requests_total", Help: "Total number of HTTP requests", }, []string{"method", "path", "status"}, ) HTTPRequestDuration = promauto.NewHistogramVec( prometheus.HistogramOpts{ Name: "http_request_duration_seconds", Help: "HTTP request duration in seconds", Buckets: prometheus.DefBuckets, }, []string{"method", "path"}, ) )
Добавьте middleware:
go// internal/middleware/logging.go package middleware import ( "log/slog" "net/http" "time" ) func Logging(logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} next.ServeHTTP(wrapped, r) logger.Info("request completed", "method", r.Method, "path", r.URL.Path, "status", wrapped.statusCode, "duration", time.Since(start).String(), ) }) } }
go// internal/middleware/metrics.go package middleware import ( "net/http" "strconv" "time" "git-manager/internal/observability" ) func Metrics() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} next.ServeHTTP(wrapped, r) duration := time.Since(start).Seconds() status := strconv.Itoa(wrapped.statusCode) observability.HTTPRequestsTotal.WithLabelValues(r.Method, r.URL.Path, status).Inc() observability.HTTPRequestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration) }) } }
День 4-7: Аутентификация и авторизация
Создайте auth middleware:
go// internal/middleware/auth.go package middleware import ( "context" "net/http" "strings" ) type contextKey string const UserIDKey contextKey = "user_id" func Auth() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader == "" { http.Error(w, "Missing authorization header", http.StatusUnauthorized) return } // Упрощённая проверка (в реальности используйте JWT) if !strings.HasPrefix(authHeader, "Bearer ") { http.Error(w, "Invalid authorization header", http.StatusUnauthorized) return } token := strings.TrimPrefix(authHeader, "Bearer ") userID := validateToken(token) // Реализуйте валидацию ctx := context.WithValue(r.Context(), UserIDKey, userID) next.ServeHTTP(w, r.WithContext(ctx)) }) } }
Неделя 7-8: Контейнеризация и финализация
День 1-3: Docker
Создайте Dockerfile:
dockerfile# docker/Dockerfile FROM golang:1.22-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/app FROM alpine:3.19 RUN apk add --no-cache ca-certificates RUN adduser -D -u 1000 appuser COPY /app/server /usr/local/bin/server USER appuser EXPOSE 8080 HEALTHCHECK \ CMD wget --spider http://localhost:8080/healthz || exit 1 ENTRYPOINT ["server"]
Создайте docker-compose:
yaml# docker/docker-compose.yml version: "3.9" services: app: build: context: .. dockerfile: docker/Dockerfile ports: - "8080:8080" environment: - DATABASE_URL=postgres://user:pass@postgres:5432/gitdb?sslmode=disable - LOG_LEVEL=info depends_on: postgres: condition: service_healthy postgres: image: postgres:16-alpine environment: - POSTGRES_USER=user - POSTGRES_PASSWORD=pass - POSTGRES_DB=gitdb ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U user -d gitdb"] interval: 5s timeout: 5s retries: 5 volumes: postgres_data:
День 4-7: Тестирование
Напишите интеграционные тесты:
go// internal/repository/postgres_repo_repository_test.go package repository import ( "context" "testing" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" ) func TestPostgresRepoRepository_Create(t *testing.T) { ctx := context.Background() container, err := postgres.Run(ctx, "postgres:16-alpine", postgres.WithDatabase("testdb"), postgres.WithUsername("test"), postgres.WithPassword("test"), ) if err != nil { t.Fatal(err) } defer container.Terminate(ctx) dsn, err := container.ConnectionString(ctx) if err != nil { t.Fatal(err) } db, err := sql.Open("postgres", dsn) if err != nil { t.Fatal(err) } defer db.Close() // Примените миграции // ... repo := NewPostgresRepoRepository(db) testRepo := &models.Repo{ ID: "test-id", Name: "test-repo", Description: "Test description", OwnerID: "user-1", IsPrivate: false, } err = repo.Create(ctx, testRepo) if err != nil { t.Errorf("Create() error = %v", err) } got, err := repo.GetByID(ctx, "test-id") if err != nil { t.Errorf("GetByID() error = %v", err) } if got.Name != testRepo.Name { t.Errorf("GetByID() name = %v, want %v", got.Name, testRepo.Name) } }
День 8-10: Документация и финальная проверка
Создайте README.md с полной документацией.
Проверьте:
- Все эндпоинты работают
- Тесты проходят
- Docker образ собирается
- Покрытие кода > 70%
Quiz "Проверь себя"
authHeader := r.Header.Get("Authorization")CreateRepoUseCase при создании репозитория?-- 001_create_repos.sql
CREATE TABLE repos (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
owner_id UUID NOT NULL
);POST /api/v1/repos при успешном создании?depends_on: postgres: condition: service_healthy в docker-compose?Критерии приёмки
Обязательные
- Реализована Clean Architecture с разделением слоёв
- Реализованы все 6 эндпоинтов API
- Подключена PostgreSQL с миграциями
- Добавлена наблюдаемость (логи, метрики, health checks)
- Реализован outbox pattern для webhook'ов
- Добавлена аутентификация и авторизация
- Сервис контейнеризирован
- Написаны тесты с покрытием > 70%
- Код отформатирован и проходит линтер
- Написана документация в README.md
Желательные
- Покрытие тестов > 80%
- Добавлен OpenAPI spec
- Реализован webhook delivery worker
- Добавлена HMAC подпись для webhook'ов
- Настроен CI pipeline
🔗 Мостик к Курсу 3
После успешного завершения проекта вы готовы к Курсу 3: Go Service Architect, где вы научитесь проектировать распределённые системы и обеспечивать их надёжность.
Удачи! 🚀