What should the proper dependency injection structure look like when working with multiple databases in Go?
Hello! I’m starting to learn server development in Go using the Gin framework. Currently, I’m setting up helper structures and dependencies. In my first project, I only used one database, but now I’m planning a large-scale project that will include PostgreSQL, MongoDB, and Redis.
The problem is as follows:
When working with a single database, my container looked like this:
type container struct {
config *config.Config
rep repository.Repository
logger logger.Logger
}
And the repository implemented methods for PostgreSQL:
type Repository interface {
Model(value interface{}) *gorm.DB
Select(query interface{}, args ...interface{}) *gorm.DB
Find(out interface{}, where ...interface{}) *gorm.DB
Exec(sql string, values ...interface{}) *gorm.DB
First(out interface{}, where ...interface{}) *gorm.DB
Raw(sql string, values ...interface{}) *gorm.DB
Create(value interface{}) *gorm.DB
Save(value interface{}) *gorm.DB
Updates(value interface{}) *gorm.DB
Delete(value interface{}) *gorm.DB
Where(query interface{}, args ...interface{}) *gorm.DB
Preload(column string, conditions ...interface{}) *gorm.DB
Scopes(funcs ...func(*gorm.DB) *gorm.DB) *gorm.DB
ScanRows(rows *sql.Rows, result interface{}) error
Transaction(fc func(tx Repository) error) (err error)
Close() error
DropTableIfExists(value interface{}) error
GetSQLDB() (*sql.DB, error)
Clauses(conds ...clause.Expression) *gorm.DB
AutoMigrate(value interface{}) error
}
type repository struct {
db *gorm.DB
}
type filmRepository struct {
*repository
}
If I put everything in one repository now, it would result in an overloaded interface. I came up with this implementation:
type Repository interface {
Postgres() PostgresRepository
Mongo() MongoRepository
Redis() RedisRepository
}
That is, store three nested interfaces for each database type in the main interface. Is this a reasonable structure? I want to do everything correctly from the start rather than having to refactor later. If there are alternative solutions for such tasks, I’m ready to listen and take note.
Correct Dependency Injection Structure When Working with Multiple Databases in Go
The proper dependency injection structure when working with multiple databases in Go should ensure separation of concerns and facilitate easy testing. Based on your approach, I can suggest several improved solutions.
Main principle: Each database should have its own repository interface and be injected separately. This follows the Interface Segregation Principle.
Contents
- Dependency Structure Approaches
- Recommended Architecture with Separated Interfaces
- Alternative Solutions
- Practical Implementation of Dependency Container
- Code Organization Tips
- Conclusions and Best Practices
Dependency Structure Approaches
1. Separate Interfaces for Each Database
This approach involves creating separate interfaces for each database and injecting them into the dependency container independently:
// Interfaces for each database
type PostgresRepository interface {
FindUser(id uint) (*User, error)
CreateUser(user *User) error
// other PostgreSQL-specific methods
}
type MongoRepository interface {
FindDocument(collection string, filter interface{}) (interface{}, error)
InsertDocument(collection string, document interface{}) error
// other MongoDB-specific methods
}
type RedisRepository interface {
Set(key string, value interface{}, expiration time.Duration) error
Get(key string) (string, error)
// other Redis-specific methods
}
2. Your Current Approach with Nested Interfaces
Your approach with a single interface and Postgres(), Mongo(), Redis() methods is a facade, which can be useful but has drawbacks:
type Repository interface {
Postgres() PostgresRepository
Mongo() MongoRepository
Redis() RedisRepository
}
Advantages:
- Simplifies dependency passing to services
- Provides a single point of access to repositories
Disadvantages:
- Creates tight coupling between components
- Complicates testing of individual repositories
- May lead to excessive dependencies
Recommended Architecture with Separated Interfaces
The best practice is to use separate interfaces for each database with injection through a dependency container:
Dependency Container Structure
type container struct {
config *config.Config
logger logger.Logger
postgresRepo PostgresRepository
mongoRepo MongoRepository
redisRepo RedisRepository
}
func NewContainer(cfg *config.Config, log logger.Logger) *container {
return &container{
config: cfg,
logger: log,
}
}
func (c *container) Postgres() PostgresRepository {
if c.postgresRepo == nil {
// Initialize PostgreSQL
db, err := gorm.Open(postgres.Open(c.config.Database.URL), &gorm.Config{})
if err != nil {
c.logger.Fatal("Failed to connect to PostgreSQL", "error", err)
}
c.postgresRepo = &postgresRepository{db: db}
}
return c.postgresRepo
}
func (c *container) Mongo() MongoRepository {
if c.mongoRepo == nil {
// Initialize MongoDB
client, err := mongo.Connect(context.Background(), options.Client().ApplyURI(c.config.MongoDB.URI))
if err != nil {
c.logger.Fatal("Failed to connect to MongoDB", "error", err)
}
c.mongoRepo = &mongoRepository{client: client}
}
return c.mongoRepo
}
func (c *container) Redis() RedisRepository {
if c.redisRepo == nil {
// Initialize Redis
rdb := redis.NewClient(&redis.Options{
Addr: c.config.Redis.Address,
})
c.redisRepo = &redisRepository{client: rdb}
}
return c.redisRepo
}
Repository Implementation Examples
PostgreSQL Repository:
type postgresRepository struct {
db *gorm.DB
}
func (r *postgresRepository) FindUser(id uint) (*User, error) {
var user User
result := r.db.First(&user, id)
if result.Error != nil {
return nil, result.Error
}
return &user, nil
}
func (r *postgresRepository) CreateUser(user *User) error {
return r.db.Create(user).Error
}
// Implementation of PostgresRepository interface
MongoDB Repository:
type mongoRepository struct {
client *mongo.Client
}
func (r *mongoRepository) FindDocument(collection string, filter interface{}) (interface{}, error) {
coll := r.client.Database("app").Collection(collection)
var result bson.M
err := coll.FindOne(context.Background(), filter).Decode(&result)
if err != nil {
return nil, err
}
return result, nil
}
func (r *mongoRepository) InsertDocument(collection string, document interface{}) error {
coll := r.client.Database("app").Collection(collection)
_, err := coll.InsertOne(context.Background(), document)
return err
}
// Implementation of MongoRepository interface
Redis Repository:
type redisRepository struct {
client *redis.Client
}
func (r *redisRepository) Set(key string, value interface{}, expiration time.Duration) error {
return r.client.Set(context.Background(), key, value, expiration).Err()
}
func (r *redisRepository) Get(key string) (string, error) {
return r.client.Get(context.Background(), key).Result()
}
// Implementation of RedisRepository interface
Alternative Solutions
1. Using Dependency Injection Frameworks
For more complex projects, you can use specialized libraries:
import "go.uber.org/dig"
func BuildContainer() *dig.Container {
container := dig.New()
container.Provide(func() (PostgresRepository, error) {
// Initialize PostgreSQL
db, err := gorm.Open(postgres.Open("..."), &gorm.Config{})
return &postgresRepository{db: db}, err
})
container.Provide(func() (MongoRepository, error) {
// Initialize MongoDB
client, err := mongo.Connect(context.Background(), options.Client().ApplyURI("..."))
return &mongoRepository{client: client}, err
})
container.Provide(func() (RedisRepository, error) {
// Initialize Redis
rdb := redis.NewClient(&redis.Options{Addr: "..."})
return &redisRepository{client: rdb}, nil
})
return container
}
2. Service Layer with Aggregation
Create services that use multiple repositories:
type UserService struct {
postgresRepo PostgresRepository
mongoRepo MongoRepository
redisRepo RedisRepository
}
func NewUserService(postgres PostgresRepository, mongo MongoRepository, redis RedisRepository) *UserService {
return &UserService{
postgresRepo: postgres,
mongoRepo: mongo,
redisRepo: redis,
}
}
func (s *UserService) GetUserProfile(userID uint) (*UserProfile, error) {
// Get data from PostgreSQL
user, err := s.postgresRepo.FindUser(userID)
if err != nil {
return nil, err
}
// Get additional data from MongoDB
profileData, err := s.mongoRepo.FindDocument("profiles", bson.M{"user_id": userID})
if err != nil {
return nil, err
}
// Cache in Redis
s.redisRepo.Set(fmt.Sprintf("user:%d", userID), profileData, time.Hour)
// Combine profile
return &UserProfile{
User: user,
Profile: profileData,
}, nil
}
Practical Implementation of Dependency Container
Here’s a complete implementation of a dependency container with lazy initialization:
package di
import (
"context"
"log"
"time"
"github.com/go-redis/redis/v8"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
type Container struct {
config *Config
logger Logger
postgresRepo PostgresRepository
mongoRepo MongoRepository
redisRepo RedisRepository
}
type Config struct {
Database DatabaseConfig
MongoDB MongoDBConfig
Redis RedisConfig
}
type DatabaseConfig struct {
URL string
}
type MongoDBConfig struct {
URI string
}
type RedisConfig struct {
Address string
}
type Logger interface {
Fatal(msg string, fields ...interface{})
}
type PostgresRepository interface {
FindUser(id uint) (*User, error)
CreateUser(user *User) error
// Other PostgreSQL methods
}
type MongoRepository interface {
FindDocument(collection string, filter interface{}) (interface{}, error)
InsertDocument(collection string, document interface{}) error
// Other MongoDB methods
}
type RedisRepository interface {
Set(key string, value interface{}, expiration time.Duration) error
Get(key string) (string, error)
// Other Redis methods
}
func NewContainer(cfg *Config, log Logger) *Container {
return &Container{
config: cfg,
logger: log,
}
}
// PostgreSQL
func (c *Container) Postgres() PostgresRepository {
if c.postgresRepo == nil {
db, err := gorm.Open(postgres.Open(c.config.Database.URL), &gorm.Config{})
if err != nil {
c.logger.Fatal("Failed to connect to PostgreSQL", "error", err)
}
c.postgresRepo = &postgresRepository{db: db}
}
return c.postgresRepo
}
// MongoDB
func (c *Container) Mongo() MongoRepository {
if c.mongoRepo == nil {
client, err := mongo.Connect(context.Background(), options.Client().ApplyURI(c.config.MongoDB.URI))
if err != nil {
c.logger.Fatal("Failed to connect to MongoDB", "error", err)
}
c.mongoRepo = &mongoRepository{client: client}
}
return c.mongoRepo
}
// Redis
func (c *Container) Redis() RedisRepository {
if c.redisRepo == nil {
rdb := redis.NewClient(&redis.Options{
Addr: c.config.Redis.Address,
})
c.redisRepo = &redisRepository{client: rdb}
}
return c.redisRepo
}
// Close all connections
func (c *Container) Close() error {
var errors []error
if c.redisRepo != nil {
if err := c.redisRepo.(*redisRepository).client.Close(); err != nil {
errors = append(errors, err)
}
}
if c.mongoRepo != nil {
if err := c.mongoRepo.(*mongoRepository).client.Disconnect(context.Background()); err != nil {
errors = append(errors, err)
}
}
if len(errors) > 0 {
return errors[0] // Return first error
}
return nil
}
Code Organization Tips
1. Project Structure
/project
/cmd
/server
main.go
/internal
/config
config.go
/di
container.go
/repositories
/postgres
repository.go
user_repository.go
/mongo
repository.go
document_repository.go
/redis
repository.go
cache_repository.go
/services
user_service.go
document_service.go
/models
user.go
document.go
/pkg
/database
postgres.go
mongo.go
redis.go
2. Proper Testing
Use mocks for testing services:
type MockPostgresRepository struct {
users map[uint]*User
}
func (m *MockPostgresRepository) FindUser(id uint) (*User, error) {
user, exists := m.users[id]
if !exists {
return nil, gorm.ErrRecordNotFound
}
return user, nil
}
func (m *MockPostgresRepository) CreateUser(user *User) error {
m.users[user.ID] = user
return nil
}
// Usage in test
func TestUserService_GetUser(t *testing.T) {
mockPostgres := &MockPostgresRepository{
users: map[uint]*User{
1: {ID: 1, Name: "Test User"},
},
}
userService := NewUserService(mockPostgres, nil, nil)
user, err := userService.GetUser(1)
assert.NoError(t, err)
assert.Equal(t, "Test User", user.Name)
}
3. Configuration
Use structured configuration:
type Config struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
MongoDB MongoDBConfig `yaml:"mongodb"`
Redis RedisConfig `yaml:"redis"`
}
type ServerConfig struct {
Port int `yaml:"port"`
}
// Load configuration
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
Conclusions and Best Practices
Key Recommendations:
- Use separate interfaces for each database instead of one large interface
- Inject dependencies through a container with lazy initialization
- Follow the Interface Segregation Principle
- Test each repository separately using mocks
- Use a service layer for business logic that combines data from different sources
Why Your Current Approach May Be Suboptimal:
- Tight Coupling: Services that only need Redis will be forced to depend on an interface that includes MongoDB and PostgreSQL
- Complicated Testing: Testing services becomes more difficult due to the need to mock all three repositories
- Scalability Issues: When adding a new database, you’ll need to modify the main interface
Alternative Approaches to Consider:
- Factory Approach: Create a factory for repositories
- Adapters: Use the adapter pattern to unify interfaces
- Contextual Dependencies: Pass required repositories through the HTTP request context
According to the Three Dots Labs blog, “The repository pattern works pretty nicely with Golang” when properly implemented with separate interfaces for different database types.
The correct dependency injection structure with multiple databases in Go should be flexible, testable, and easily scalable. Separating interfaces and using lazy initialization through a dependency container provides these qualities.