Currency Service — end-to-end¶
Архитектура¶
graph LR
Client -->|REST| GW[gateway]
GW -->|gRPC| CS[currency-service]
CS --> DB[(PostgreSQL)]
CS --> R[(Redis cache)]
CRON[cron worker] -->|fetch CBR| EXT[CBR API]
CRON --> DB
CS --> Prom[Prometheus]
Prom --> Grafana
Структура репозитория¶
currency-service/
├── cmd/
│ ├── server/main.go # gRPC server
│ ├── cron/main.go # фоновый воркер: загрузка CBR
│ └── migrator/main.go # миграции
├── internal/
│ ├── config/ # viper + ENV
│ ├── domain/ # модели + ошибки
│ ├── grpcserver/ # хэндлеры gRPC
│ ├── repository/ # DAO к Postgres
│ ├── service/ # бизнес-логика
│ ├── cron/ # job scheduler
│ ├── cache/ # Redis-обёртка
│ └── observability/ # Prometheus, log
├── pkg/proto/ # .proto + сгенерированные _grpc.pb.go
├── migrations/
│ └── 0001_init.up.sql
├── deployment/
│ ├── docker/Dockerfile
│ └── compose.yml
└── README.md
gateway/
├── cmd/gateway/main.go # chi REST
├── internal/handlers/ # REST хэндлеры → gRPC
└── deployment/...
Этапы разработки (~14 шагов)¶
M0 — Скелет¶
go mod init- Standard layout (см. выше)
- Makefile с базовыми таргетами:
run,build,test,lint,proto
M1 — gRPC контракт¶
service Currency {
rpc GetRate(GetRateRequest) returns (GetRateResponse);
rpc ListRates(ListRatesRequest) returns (ListRatesResponse);
}
protocчерез buf (рекомендуется) или вручную- сгенерированный код в
pkg/proto/currency_grpc.pb.go
M2 — Postgres + миграции¶
CREATE TABLE rates (
id BIGSERIAL PRIMARY KEY,
base TEXT NOT NULL,
quote TEXT NOT NULL,
rate NUMERIC(18, 6) NOT NULL,
fetched_at TIMESTAMPTZ NOT NULL,
UNIQUE (base, quote, fetched_at)
);
CREATE INDEX idx_rates_pair ON rates(base, quote, fetched_at DESC);
golang-migrateчерез CLI и через embedpgxpool.Newдля коннекшен пула
M3 — Repository слой¶
type RatesRepo interface {
GetLatest(ctx context.Context, base, quote string) (*Rate, error)
Save(ctx context.Context, r *Rate) error
ListLatest(ctx context.Context, base string) ([]*Rate, error)
}
M4 — Service слой¶
Бизнес-логика: запросить cache → если нет, идём в БД → populate cache.
func (s *Service) GetRate(ctx context.Context, base, quote string) (*Rate, error) {
if r, ok := s.cache.Get(base + ":" + quote); ok {
return r, nil
}
r, err := s.repo.GetLatest(ctx, base, quote)
if err != nil { return nil, err }
s.cache.Set(base+":"+quote, r, 5*time.Minute)
return r, nil
}
M5 — gRPC handler¶
Тонкий слой: парсит request, вызывает service, мапит ошибки в status.Error.
// internal/grpcserver/handlers.go
package grpcserver
import (
"context"
"errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/yourorg/currency/internal/domain"
pb "github.com/yourorg/currency/pkg/proto"
)
type CurrencyHandler struct {
pb.UnimplementedCurrencyServer
svc Service
}
type Service interface {
GetRate(ctx context.Context, base, quote string) (*domain.Rate, error)
}
func (h *CurrencyHandler) GetRate(ctx context.Context, in *pb.GetRateRequest) (*pb.GetRateResponse, error) {
if in.Base == "" || in.Quote == "" {
return nil, status.Error(codes.InvalidArgument, "base and quote required")
}
r, err := h.svc.GetRate(ctx, in.Base, in.Quote)
switch {
case errors.Is(err, domain.ErrNotFound):
return nil, status.Error(codes.NotFound, "rate not found")
case err != nil:
return nil, status.Error(codes.Internal, "internal error")
}
return &pb.GetRateResponse{
Base: r.Base,
Quote: r.Quote,
Rate: r.Rate,
FetchedAt: r.FetchedAt.Unix(),
}, nil
}
Идиома: handler не делает бизнес-логику, только трансляция (proto ↔ domain) и маппинг ошибок в коды gRPC. Это упрощает тесты — service слой проверяется без запуска gRPC сервера.
M6 — Cron worker¶
Отдельный бинарь cmd/cron/main.go:
ticker := time.NewTicker(time.Hour)
for {
select {
case <-ticker.C:
rates, err := fetchCBR(ctx)
for _, r := range rates {
s.repo.Save(ctx, r)
}
case <-ctx.Done():
return
}
}
M7 — Gateway (REST → gRPC)¶
r := chi.NewRouter()
r.Get("/api/rate", func(w http.ResponseWriter, req *http.Request) {
base := req.URL.Query().Get("base")
quote := req.URL.Query().Get("quote")
rate, err := grpcClient.GetRate(req.Context(), &pb.GetRateRequest{Base: base, Quote: quote})
json.NewEncoder(w).Encode(rate)
})
M8 — Конфиг¶
internal/config/config.go через viper:
grpc_addr: ":50051"
postgres_dsn: "${POSTGRES_DSN}"
redis_addr: "${REDIS_ADDR}"
cache_ttl: 5m
log_level: info
ENV переопределяет YAML.
M9 — Observability¶
- Prometheus:
/metricsendpoint, гистограммыgrpc_request_duration_seconds, счётчикиcbr_fetch_total{status="..."}. - Логи:
slogилиzapс request_id middleware. - Trace ID через context.
M10 — Тесты¶
- Unit: service слой через mock'и (gomock).
- Integration: testcontainers-go поднимает Postgres+Redis в Docker.
- E2E: docker-compose + curl/grpcurl.
Минимальный пример unit-теста через generated мок:
func TestService_GetRate_CacheHit(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
repo := mocks.NewMockRatesRepo(ctrl)
cache := mocks.NewMockCache(ctrl)
cached := &domain.Rate{Base: "USD", Quote: "RUB", Rate: 95.5, FetchedAt: time.Now()}
cache.EXPECT().Get("USD:RUB").Return(cached, true)
// На cache hit в репозиторий идти не должны.
repo.EXPECT().GetLatest(gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
svc := service.New(repo, cache, slog.Default())
got, err := svc.GetRate(context.Background(), "USD", "RUB")
require.NoError(t, err)
require.Equal(t, 95.5, got.Rate)
}
Интеграционный тест с testcontainers-go:
func TestRepo_SaveAndGet(t *testing.T) {
ctx := context.Background()
pgC, err := postgres.RunContainer(ctx,
testcontainers.WithImage("postgres:16-alpine"),
postgres.WithDatabase("test"),
postgres.WithUsername("u"), postgres.WithPassword("p"),
testcontainers.WithWaitStrategy(wait.ForLog("ready to accept connections").WithOccurrence(2)),
)
require.NoError(t, err)
defer pgC.Terminate(ctx)
dsn, _ := pgC.ConnectionString(ctx, "sslmode=disable")
pool, _ := pgxpool.New(ctx, dsn)
require.NoError(t, repository.MigrateUp(ctx, dsn))
r := repository.NewRatesRepo(pool)
require.NoError(t, r.Save(ctx, &domain.Rate{Base: "USD", Quote: "RUB", Rate: 95.5, FetchedAt: time.Now()}))
got, err := r.GetLatest(ctx, "USD", "RUB")
require.NoError(t, err)
require.Equal(t, 95.5, got.Rate)
}
Тесты гоняем с -race, в CI блокирующее условие — все зелёные.
M11 — Outbox (опционально, +зачётка)¶
Если нужно публиковать события «новый курс» в Kafka — outbox-таблица + worker.
M12 — Graceful shutdown¶
Полный канонический пример для cmd/server/main.go — лови SIGTERM, останови gRPC сервер, дождись текущих запросов, закрой пул БД:
package main
import (
"context"
"errors"
"log/slog"
"net"
"os"
"os/signal"
"syscall"
"time"
"google.golang.org/grpc"
"github.com/yourorg/currency/internal/config"
"github.com/yourorg/currency/internal/grpcserver"
"github.com/yourorg/currency/internal/repository"
"github.com/yourorg/currency/internal/service"
pb "github.com/yourorg/currency/pkg/proto"
)
func main() {
log := slog.Default()
cfg, err := config.Load()
if err != nil {
log.Error("load config", "err", err)
os.Exit(1)
}
pool, err := repository.NewPool(context.Background(), cfg.PostgresDSN)
if err != nil {
log.Error("connect db", "err", err)
os.Exit(1)
}
defer pool.Close()
repo := repository.NewRatesRepo(pool)
cache := service.NewRedisCache(cfg.RedisAddr)
svc := service.New(repo, cache, log)
grpcSrv := grpc.NewServer()
pb.RegisterCurrencyServer(grpcSrv, &grpcserver.CurrencyHandler{Svc: svc})
lis, err := net.Listen("tcp", cfg.GRPCAddr)
if err != nil {
log.Error("listen", "err", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
serveErr := make(chan error, 1)
go func() {
log.Info("grpc serving", "addr", cfg.GRPCAddr)
if err := grpcSrv.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
serveErr <- err
}
}()
select {
case <-ctx.Done():
log.Info("shutdown signal received")
case err := <-serveErr:
log.Error("serve error", "err", err)
}
// 1) Перестаём принимать новые соединения, ждём активные запросы.
done := make(chan struct{})
go func() {
grpcSrv.GracefulStop()
close(done)
}()
select {
case <-done:
log.Info("grpc stopped gracefully")
case <-time.After(20 * time.Second):
log.Warn("graceful stop timeout, forcing")
grpcSrv.Stop()
}
}
Паттерн signal.NotifyContext + два select'а — самый частый каркас на собесах,
учим его наизусть.
M13 — Docker compose¶
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: cs
POSTGRES_PASSWORD: cs
redis:
image: redis:7-alpine
currency:
build: .
depends_on: [postgres, redis]
environment:
POSTGRES_DSN: postgres://cs:cs@postgres:5432/cs
REDIS_ADDR: redis:6379
cron:
build: .
command: ["/cron"]
gateway:
build: ./gateway
M14 — Документация и CI¶
- README с quickstart (
docker-compose up) .gitlab-ci.yml: lint + test + buildmakeс base-таргетами
Acceptance criteria (из RUBRIC.md)¶
- Сервис стартует через
docker compose up, healthcheck зелёный. - gRPC GetRate возвращает корректные данные.
- REST gateway проксирует к gRPC.
- Cron worker реально дёргает CBR раз в час.
- Миграции применяются автоматически.
- Prometheus метрики доступны на /metrics.
- Race detector чист (
go test -race). - Покрытие service слоя ≥ 60%.
- Graceful shutdown по SIGTERM.
- README с quickstart и описанием API.
Что говорить на собесе про этот проект¶
- «Стандартный layout, отделил cmd / internal / pkg».
- «gRPC контракт, через protoc генерирую».
- «Repository pattern для тестируемости».
- «Cron worker — отдельный процесс, не в основном сервере».
- «Cache → Postgres → CBR — три уровня хранения».
- «Observability: Prometheus, RED, structured logs с request_id».
- «Race detector, testcontainers для интеграционных».
Всё это — реальные практики, не выдумка.