refactor: migrate DI to Wire, replace startup registries with on-demand DB queries

- Add google/wire; generate wire_gen.go from wire.go injector
- Move all constructor wiring out of main.go into providers.go / wire.go
- Export recognition.IngredientRepository (was unexported, blocked Wire binding)
- Delete units/cuisine/tag registry.go files (global state + unused NameFor helpers)
- Replace List handlers with NewListHandler(pool) using COALESCE SQL queries
- Remove units/cuisine/tag LoadFromDB from server startup; locale.LoadFromDB kept
  (locale.Supported is used by language middleware on every request)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-15 18:45:21 +02:00
parent 61feb91bba
commit 0ce111fa08
14 changed files with 552 additions and 423 deletions

View File

@@ -10,177 +10,51 @@ import (
"syscall"
"time"
"github.com/food-ai/backend/internal/auth"
"github.com/food-ai/backend/internal/config"
"github.com/food-ai/backend/internal/cuisine"
"github.com/food-ai/backend/internal/database"
"github.com/food-ai/backend/internal/diary"
"github.com/food-ai/backend/internal/dish"
"github.com/food-ai/backend/internal/gemini"
"github.com/food-ai/backend/internal/home"
"github.com/food-ai/backend/internal/ingredient"
"github.com/food-ai/backend/internal/locale"
"github.com/food-ai/backend/internal/menu"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/units"
"github.com/food-ai/backend/internal/pexels"
"github.com/food-ai/backend/internal/product"
"github.com/food-ai/backend/internal/recipe"
"github.com/food-ai/backend/internal/recognition"
"github.com/food-ai/backend/internal/recommendation"
"github.com/food-ai/backend/internal/savedrecipe"
"github.com/food-ai/backend/internal/server"
"github.com/food-ai/backend/internal/tag"
"github.com/food-ai/backend/internal/user"
)
// jwtAdapter adapts auth.JWTManager to middleware.AccessTokenValidator.
type jwtAdapter struct {
jm *auth.JWTManager
}
func (a *jwtAdapter) ValidateAccessToken(tokenStr string) (*middleware.TokenClaims, error) {
claims, err := a.jm.ValidateAccessToken(tokenStr)
if err != nil {
return nil, err
}
return &middleware.TokenClaims{
UserID: claims.UserID,
Plan: claims.Plan,
}, nil
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
if err := run(); err != nil {
slog.Error("fatal error", "err", err)
if runError := run(); runError != nil {
slog.Error("fatal error", "err", runError)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
appConfig, configError := config.Load()
if configError != nil {
return fmt.Errorf("load config: %w", configError)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
applicationContext, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
pool, err := database.NewPool(ctx, cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("connect to database: %w", err)
pool, poolError := database.NewPool(applicationContext, appConfig.DatabaseURL)
if poolError != nil {
return fmt.Errorf("connect to database: %w", poolError)
}
defer pool.Close()
slog.Info("connected to database")
if err := locale.LoadFromDB(ctx, pool); err != nil {
return fmt.Errorf("load languages: %w", err)
if loadError := locale.LoadFromDB(applicationContext, pool); loadError != nil {
return fmt.Errorf("load languages: %w", loadError)
}
slog.Info("languages loaded", "count", len(locale.Languages))
if err := units.LoadFromDB(ctx, pool); err != nil {
return fmt.Errorf("load units: %w", err)
}
slog.Info("units loaded", "count", len(units.Records))
if err := cuisine.LoadFromDB(ctx, pool); err != nil {
return fmt.Errorf("load cuisines: %w", err)
}
slog.Info("cuisines loaded", "count", len(cuisine.Records))
if err := tag.LoadFromDB(ctx, pool); err != nil {
return fmt.Errorf("load tags: %w", err)
}
slog.Info("tags loaded", "count", len(tag.Records))
// Firebase auth
firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile)
if err != nil {
return fmt.Errorf("init firebase auth: %w", err)
router, initError := initRouter(appConfig, pool)
if initError != nil {
return fmt.Errorf("init router: %w", initError)
}
// JWT manager
jwtManager := auth.NewJWTManager(cfg.JWTSecret, cfg.JWTAccessDuration, cfg.JWTRefreshDuration)
// User domain
userRepo := user.NewRepository(pool)
userService := user.NewService(userRepo)
userHandler := user.NewHandler(userService)
// Auth domain
authService := auth.NewService(firebaseAuth, userRepo, jwtManager)
authHandler := auth.NewHandler(authService)
// Auth middleware
authMW := middleware.Auth(&jwtAdapter{jm: jwtManager})
// External API clients
geminiClient := gemini.NewClient(cfg.OpenAIAPIKey)
pexelsClient := pexels.NewClient(cfg.PexelsAPIKey)
// Ingredient domain
ingredientRepo := ingredient.NewRepository(pool)
ingredientHandler := ingredient.NewHandler(ingredientRepo)
// Product domain
productRepo := product.NewRepository(pool)
productHandler := product.NewHandler(productRepo)
// Recognition domain
recognitionHandler := recognition.NewHandler(geminiClient, ingredientRepo)
// Recommendation domain
recommendationHandler := recommendation.NewHandler(geminiClient, pexelsClient, userRepo, productRepo)
// Dish domain
dishRepo := dish.NewRepository(pool)
dishHandler := dish.NewHandler(dishRepo)
// Recipe domain
recipeRepo := recipe.NewRepository(pool)
recipeHandler := recipe.NewHandler(recipeRepo)
// Saved recipes domain
savedRecipeRepo := savedrecipe.NewRepository(pool, dishRepo)
savedRecipeHandler := savedrecipe.NewHandler(savedRecipeRepo)
// Menu domain
menuRepo := menu.NewRepository(pool)
menuHandler := menu.NewHandler(menuRepo, geminiClient, pexelsClient, userRepo, productRepo, dishRepo)
// Diary domain
diaryRepo := diary.NewRepository(pool)
diaryHandler := diary.NewHandler(diaryRepo)
// Home domain
homeHandler := home.NewHandler(pool)
// Router
router := server.NewRouter(
pool,
authHandler,
userHandler,
recommendationHandler,
savedRecipeHandler,
ingredientHandler,
productHandler,
recognitionHandler,
menuHandler,
diaryHandler,
homeHandler,
dishHandler,
recipeHandler,
authMW,
cfg.AllowedOrigins,
)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
httpServer := &http.Server{
Addr: fmt.Sprintf(":%d", appConfig.Port),
Handler: router,
ReadTimeout: 10 * time.Second,
WriteTimeout: 120 * time.Second, // menu generation can take ~60s
@@ -188,17 +62,17 @@ func run() error {
}
go func() {
slog.Info("server starting", "port", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "err", err)
slog.Info("server starting", "port", appConfig.Port)
if serverError := httpServer.ListenAndServe(); serverError != nil && serverError != http.ErrServerClosed {
slog.Error("server error", "err", serverError)
}
}()
<-ctx.Done()
<-applicationContext.Done()
slog.Info("shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
return httpServer.Shutdown(shutdownContext)
}

View File

@@ -0,0 +1,204 @@
package main
import (
"net/http"
"time"
"github.com/food-ai/backend/internal/auth"
"github.com/food-ai/backend/internal/config"
"github.com/food-ai/backend/internal/diary"
"github.com/food-ai/backend/internal/dish"
"github.com/food-ai/backend/internal/gemini"
"github.com/food-ai/backend/internal/home"
"github.com/food-ai/backend/internal/ingredient"
"github.com/food-ai/backend/internal/menu"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/pexels"
"github.com/food-ai/backend/internal/product"
"github.com/food-ai/backend/internal/recipe"
"github.com/food-ai/backend/internal/recognition"
"github.com/food-ai/backend/internal/recommendation"
"github.com/food-ai/backend/internal/savedrecipe"
"github.com/food-ai/backend/internal/cuisine"
"github.com/food-ai/backend/internal/server"
"github.com/food-ai/backend/internal/tag"
"github.com/food-ai/backend/internal/units"
"github.com/food-ai/backend/internal/user"
"github.com/jackc/pgx/v5/pgxpool"
)
// ---------------------------------------------------------------------------
// Newtypes for config primitives — prevents Wire type collisions.
// ---------------------------------------------------------------------------
// Newtypes for list handlers — prevents Wire type collisions on http.HandlerFunc.
type unitsListHandler http.HandlerFunc
type cuisineListHandler http.HandlerFunc
type tagListHandler http.HandlerFunc
// ---------------------------------------------------------------------------
type geminiAPIKey string
type pexelsAPIKey string
type jwtSecret string
type jwtAccessDuration time.Duration
type jwtRefreshDuration time.Duration
type allowedOrigins []string
// ---------------------------------------------------------------------------
// Config extractors
// ---------------------------------------------------------------------------
func newGeminiAPIKey(appConfig *config.Config) geminiAPIKey {
return geminiAPIKey(appConfig.OpenAIAPIKey)
}
func newPexelsAPIKey(appConfig *config.Config) pexelsAPIKey {
return pexelsAPIKey(appConfig.PexelsAPIKey)
}
func newJWTSecret(appConfig *config.Config) jwtSecret {
return jwtSecret(appConfig.JWTSecret)
}
func newJWTAccessDuration(appConfig *config.Config) jwtAccessDuration {
return jwtAccessDuration(appConfig.JWTAccessDuration)
}
func newJWTRefreshDuration(appConfig *config.Config) jwtRefreshDuration {
return jwtRefreshDuration(appConfig.JWTRefreshDuration)
}
func newAllowedOrigins(appConfig *config.Config) allowedOrigins {
return allowedOrigins(appConfig.AllowedOrigins)
}
// newFirebaseCredentialsFile is the only string that reaches NewFirebaseAuthOrNoop,
// so no newtype is needed here.
func newFirebaseCredentialsFile(appConfig *config.Config) string {
return appConfig.FirebaseCredentialsFile
}
// ---------------------------------------------------------------------------
// Constructor wrappers for functions that accept primitive newtypes
// ---------------------------------------------------------------------------
func newGeminiClient(key geminiAPIKey) *gemini.Client {
return gemini.NewClient(string(key))
}
func newPexelsClient(key pexelsAPIKey) *pexels.Client {
return pexels.NewClient(string(key))
}
func newJWTManager(
secret jwtSecret,
accessDuration jwtAccessDuration,
refreshDuration jwtRefreshDuration,
) *auth.JWTManager {
return auth.NewJWTManager(string(secret), time.Duration(accessDuration), time.Duration(refreshDuration))
}
// ---------------------------------------------------------------------------
// List handler providers
// ---------------------------------------------------------------------------
func newUnitsListHandler(pool *pgxpool.Pool) unitsListHandler {
return unitsListHandler(units.NewListHandler(pool))
}
func newCuisineListHandler(pool *pgxpool.Pool) cuisineListHandler {
return cuisineListHandler(cuisine.NewListHandler(pool))
}
func newTagListHandler(pool *pgxpool.Pool) tagListHandler {
return tagListHandler(tag.NewListHandler(pool))
}
// ---------------------------------------------------------------------------
// newRouter wraps server.NewRouter to accept newtypes.
func newRouter(
pool *pgxpool.Pool,
authHandler *auth.Handler,
userHandler *user.Handler,
recommendationHandler *recommendation.Handler,
savedRecipeHandler *savedrecipe.Handler,
ingredientHandler *ingredient.Handler,
productHandler *product.Handler,
recognitionHandler *recognition.Handler,
menuHandler *menu.Handler,
diaryHandler *diary.Handler,
homeHandler *home.Handler,
dishHandler *dish.Handler,
recipeHandler *recipe.Handler,
authMiddleware func(http.Handler) http.Handler,
origins allowedOrigins,
unitsHandler unitsListHandler,
cuisineHandler cuisineListHandler,
tagHandler tagListHandler,
) http.Handler {
return server.NewRouter(
pool,
authHandler,
userHandler,
recommendationHandler,
savedRecipeHandler,
ingredientHandler,
productHandler,
recognitionHandler,
menuHandler,
diaryHandler,
homeHandler,
dishHandler,
recipeHandler,
authMiddleware,
[]string(origins),
http.HandlerFunc(unitsHandler),
http.HandlerFunc(cuisineHandler),
http.HandlerFunc(tagHandler),
)
}
// ---------------------------------------------------------------------------
// jwtAdapter — adapts *auth.JWTManager to middleware.AccessTokenValidator.
// ---------------------------------------------------------------------------
type jwtAdapter struct {
jwtManager *auth.JWTManager
}
func newJWTAdapter(jm *auth.JWTManager) *jwtAdapter {
return &jwtAdapter{jwtManager: jm}
}
func (adapter *jwtAdapter) ValidateAccessToken(tokenStr string) (*middleware.TokenClaims, error) {
claims, validationError := adapter.jwtManager.ValidateAccessToken(tokenStr)
if validationError != nil {
return nil, validationError
}
return &middleware.TokenClaims{
UserID: claims.UserID,
Plan: claims.Plan,
}, nil
}
// newAuthMiddleware wraps middleware.Auth for Wire injection.
func newAuthMiddleware(validator middleware.AccessTokenValidator) func(http.Handler) http.Handler {
return middleware.Auth(validator)
}
// ---------------------------------------------------------------------------
// Interface assertions (compile-time checks)
// ---------------------------------------------------------------------------
var _ middleware.AccessTokenValidator = (*jwtAdapter)(nil)
var _ menu.PhotoSearcher = (*pexels.Client)(nil)
var _ menu.UserLoader = (*user.Repository)(nil)
var _ menu.ProductLister = (*product.Repository)(nil)
var _ menu.RecipeSaver = (*dish.Repository)(nil)
var _ recommendation.PhotoSearcher = (*pexels.Client)(nil)
var _ recommendation.UserLoader = (*user.Repository)(nil)
var _ recommendation.ProductLister = (*product.Repository)(nil)
var _ recognition.IngredientRepository = (*ingredient.Repository)(nil)
var _ user.UserRepository = (*user.Repository)(nil)

111
backend/cmd/server/wire.go Normal file
View File

@@ -0,0 +1,111 @@
//go:build wireinject
package main
import (
"net/http"
"github.com/food-ai/backend/internal/auth"
"github.com/food-ai/backend/internal/config"
"github.com/food-ai/backend/internal/diary"
"github.com/food-ai/backend/internal/dish"
"github.com/food-ai/backend/internal/home"
"github.com/food-ai/backend/internal/ingredient"
"github.com/food-ai/backend/internal/menu"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/pexels"
"github.com/food-ai/backend/internal/product"
"github.com/food-ai/backend/internal/recipe"
"github.com/food-ai/backend/internal/recognition"
"github.com/food-ai/backend/internal/recommendation"
"github.com/food-ai/backend/internal/savedrecipe"
"github.com/food-ai/backend/internal/user"
"github.com/google/wire"
"github.com/jackc/pgx/v5/pgxpool"
)
func initRouter(appConfig *config.Config, pool *pgxpool.Pool) (http.Handler, error) {
wire.Build(
// Config extractors
newGeminiAPIKey,
newPexelsAPIKey,
newJWTSecret,
newJWTAccessDuration,
newJWTRefreshDuration,
newAllowedOrigins,
newFirebaseCredentialsFile,
// Auth
auth.NewFirebaseAuthOrNoop,
newJWTManager,
newJWTAdapter,
newAuthMiddleware,
auth.NewService,
auth.NewHandler,
// User
user.NewRepository,
user.NewService,
user.NewHandler,
// External clients
newGeminiClient,
newPexelsClient,
// Ingredient
ingredient.NewRepository,
ingredient.NewHandler,
// Product
product.NewRepository,
product.NewHandler,
// Dish
dish.NewRepository,
dish.NewHandler,
// Recipe
recipe.NewRepository,
recipe.NewHandler,
// Saved recipes
savedrecipe.NewRepository,
savedrecipe.NewHandler,
// Menu
menu.NewRepository,
menu.NewHandler,
// Diary
diary.NewRepository,
diary.NewHandler,
// Home
home.NewHandler,
// Recognition & Recommendation
recognition.NewHandler,
recommendation.NewHandler,
// List handlers (DB-backed, injected into router)
newUnitsListHandler,
newCuisineListHandler,
newTagListHandler,
// Router
newRouter,
// Interface bindings
wire.Bind(new(user.UserRepository), new(*user.Repository)),
wire.Bind(new(menu.PhotoSearcher), new(*pexels.Client)),
wire.Bind(new(menu.UserLoader), new(*user.Repository)),
wire.Bind(new(menu.ProductLister), new(*product.Repository)),
wire.Bind(new(menu.RecipeSaver), new(*dish.Repository)),
wire.Bind(new(recommendation.PhotoSearcher), new(*pexels.Client)),
wire.Bind(new(recommendation.UserLoader), new(*user.Repository)),
wire.Bind(new(recommendation.ProductLister), new(*product.Repository)),
wire.Bind(new(recognition.IngredientRepository), new(*ingredient.Repository)),
wire.Bind(new(middleware.AccessTokenValidator), new(*jwtAdapter)),
)
return nil, nil
}

View File

@@ -0,0 +1,73 @@
// Code generated by Wire. DO NOT EDIT.
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject
package main
import (
"github.com/food-ai/backend/internal/auth"
"github.com/food-ai/backend/internal/config"
"github.com/food-ai/backend/internal/diary"
"github.com/food-ai/backend/internal/dish"
"github.com/food-ai/backend/internal/home"
"github.com/food-ai/backend/internal/ingredient"
"github.com/food-ai/backend/internal/menu"
"github.com/food-ai/backend/internal/product"
"github.com/food-ai/backend/internal/recipe"
"github.com/food-ai/backend/internal/recognition"
"github.com/food-ai/backend/internal/recommendation"
"github.com/food-ai/backend/internal/savedrecipe"
"github.com/food-ai/backend/internal/user"
"github.com/jackc/pgx/v5/pgxpool"
"net/http"
)
// Injectors from wire.go:
func initRouter(appConfig *config.Config, pool *pgxpool.Pool) (http.Handler, error) {
string2 := newFirebaseCredentialsFile(appConfig)
tokenVerifier, err := auth.NewFirebaseAuthOrNoop(string2)
if err != nil {
return nil, err
}
repository := user.NewRepository(pool)
mainJwtSecret := newJWTSecret(appConfig)
mainJwtAccessDuration := newJWTAccessDuration(appConfig)
mainJwtRefreshDuration := newJWTRefreshDuration(appConfig)
jwtManager := newJWTManager(mainJwtSecret, mainJwtAccessDuration, mainJwtRefreshDuration)
service := auth.NewService(tokenVerifier, repository, jwtManager)
handler := auth.NewHandler(service)
userService := user.NewService(repository)
userHandler := user.NewHandler(userService)
mainGeminiAPIKey := newGeminiAPIKey(appConfig)
client := newGeminiClient(mainGeminiAPIKey)
mainPexelsAPIKey := newPexelsAPIKey(appConfig)
pexelsClient := newPexelsClient(mainPexelsAPIKey)
productRepository := product.NewRepository(pool)
recommendationHandler := recommendation.NewHandler(client, pexelsClient, repository, productRepository)
dishRepository := dish.NewRepository(pool)
savedrecipeRepository := savedrecipe.NewRepository(pool, dishRepository)
savedrecipeHandler := savedrecipe.NewHandler(savedrecipeRepository)
ingredientRepository := ingredient.NewRepository(pool)
ingredientHandler := ingredient.NewHandler(ingredientRepository)
productHandler := product.NewHandler(productRepository)
recognitionHandler := recognition.NewHandler(client, ingredientRepository)
menuRepository := menu.NewRepository(pool)
menuHandler := menu.NewHandler(menuRepository, client, pexelsClient, repository, productRepository, dishRepository)
diaryRepository := diary.NewRepository(pool)
diaryHandler := diary.NewHandler(diaryRepository)
homeHandler := home.NewHandler(pool)
dishHandler := dish.NewHandler(dishRepository)
recipeRepository := recipe.NewRepository(pool)
recipeHandler := recipe.NewHandler(recipeRepository)
mainJwtAdapter := newJWTAdapter(jwtManager)
v := newAuthMiddleware(mainJwtAdapter)
mainAllowedOrigins := newAllowedOrigins(appConfig)
mainUnitsListHandler := newUnitsListHandler(pool)
mainCuisineListHandler := newCuisineListHandler(pool)
mainTagListHandler := newTagListHandler(pool)
httpHandler := newRouter(pool, handler, userHandler, recommendationHandler, savedrecipeHandler, ingredientHandler, productHandler, recognitionHandler, menuHandler, diaryHandler, homeHandler, dishHandler, recipeHandler, v, mainAllowedOrigins, mainUnitsListHandler, mainCuisineListHandler, mainTagListHandler)
return httpHandler, nil
}