О проекте

Цель: закрепить навыки разработки в полноценном сервисе.

Проект: Сервис управления репозиториями (Git Manager)

Длительность: 8 недель


Описание проекта

Вы создадите полноценный REST API сервис для управления репозиториями кода. Проект включает:

  • Clean Architecture с разделением слоёв
  • PostgreSQL для хранения данных
  • Наблюдаемость (логи, метрики)
  • Фоновая обработка (outbox pattern)
  • Безопасность (аутентификация, авторизация)
  • Контейнеризация через Docker

Требования к проекту

Обязательные компоненты

  1. 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
  2. Архитектура

    • Clean Architecture с разделением на слои
    • Domain models, services, repositories
    • DTO для API контрактов
    • Валидация входных данных
  3. База данных

    • PostgreSQL для хранения
    • Миграции через goose
    • Транзакции для атомарных операций
  4. Наблюдаемость

    • Structured logging через slog
    • Prometheus метрики
    • Health checks (/healthz, /readyz)
  5. Фоновая обработка

    • Outbox pattern для webhook delivery
    • Background worker для отправки webhook'ов
  6. Безопасность

    • JWT аутентификация
    • Проверка владения ресурсом
    • Валидация входных данных
    • HMAC подпись для webhook'ов
  7. Контейнеризация

    • Multi-stage Dockerfile
    • Docker Compose для локальной разработки
  8. Тестирование

    • Юнит-тесты для всех слоёв
    • Интеграционные тесты с 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: Инициализация проекта

Создайте структуру проекта:

bash
mkdir -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 --from=builder /app/server /usr/local/bin/server

USER appuser

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
    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 "Проверь себя"

1. Как тестируют repository слой и какой порог покрытия обязателен?
2. Как обеспечивается подлинность webhook при доставке?
3. Что из перечисленного обязательно в блоке «Безопасность» проекта? — выберите все подходящие
4. Откуда auth middleware берёт идентификатор пользователя?
go
authHeader := r.Header.Get("Authorization")
5. Какие обязательные компоненты входят в практический проект Git Manager? — выберите все подходящие
6. Что делает CreateRepoUseCase при создании репозитория?
7. Какой код отвечает за применение миграций в интеграционных тестах проекта?
sql
-- 001_create_repos.sql
CREATE TABLE repos (
    id UUID PRIMARY KEY,
    name TEXT NOT NULL,
    owner_id UUID NOT NULL
);
8. Какой код ответа вернёт handler POST /api/v1/repos при успешном создании?
9. Что делает запись depends_on: postgres: condition: service_healthy в docker-compose?
10. Как воркер доставляет outbox-события (webhook delivery)?

Критерии приёмки

Обязательные

  • Реализована 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, где вы научитесь проектировать распределённые системы и обеспечивать их надёжность.

Удачи! 🚀