Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ A production-ready Go project template following Clean Architecture and Domain-D

- **Modular Domain Architecture** - Domain-based code organization for scalability
- **Clean Architecture** - Separation of concerns with domain, repository, use case, and presentation layers
- **Dependency Injection Container** - Centralized component wiring with lazy initialization and clean resource management
- **Multiple Database Support** - PostgreSQL and MySQL via unified repository layer
- **Database Migrations** - Separate migrations for PostgreSQL and MySQL using golang-migrate
- **Transaction Management** - TxManager interface for handling database transactions
Expand All @@ -29,6 +30,10 @@ go-project-template/
│ └── app/ # Application entry point
│ └── main.go
├── internal/
│ ├── app/ # Dependency injection container
│ │ ├── di.go
│ │ ├── di_test.go
│ │ └── README.md
│ ├── config/ # Configuration management
│ │ └── config.go
│ ├── database/ # Database connection and transaction management
Expand Down Expand Up @@ -84,6 +89,7 @@ The project follows a modular domain architecture where each business domain is

### Shared Utilities

- **`app/`** - Dependency injection container for assembling application components
- **`httputil/`** - Shared HTTP utility functions used across all domain modules (e.g., `MakeJSONResponse`)
- **`config/`** - Application-wide configuration
- **`database/`** - Database connection and transaction management
Expand Down Expand Up @@ -330,6 +336,44 @@ make docker-run-migrate

## Architecture

### Dependency Injection Container

The project uses a custom dependency injection (DI) container located in `internal/app/` to manage all application components. This provides:

- **Centralized component wiring** - All dependencies are assembled in one place
- **Lazy initialization** - Components are only created when first accessed
- **Singleton pattern** - Each component is initialized once and reused
- **Clean resource management** - Unified shutdown for all resources
- **Thread-safe** - Safe for concurrent access across goroutines

**Example usage:**

```go
// Create container with configuration
container := app.NewContainer(cfg)

// Get HTTP server (automatically initializes all dependencies)
server, err := container.HTTPServer()
if err != nil {
return fmt.Errorf("failed to initialize HTTP server: %w", err)
}

// Clean shutdown
defer container.Shutdown(ctx)
```

The container manages the entire dependency graph:

```
Container
├── Infrastructure (Database, Logger)
├── Repositories (User, Outbox)
├── Use Cases (User)
└── Presentation (HTTP Server, Worker)
```

For more details on the DI container, see [`internal/app/README.md`](internal/app/README.md).

### Modular Domain Architecture

The project follows a modular domain-driven structure where each business domain is self-contained:
Expand All @@ -346,6 +390,7 @@ The project follows a modular domain-driven structure where each business domain
- `repository/` - Event persistence and retrieval

**Shared Infrastructure**
- `app/` - Dependency injection container for component assembly
- `config/` - Application configuration
- `database/` - Database connection and transaction management
- `http/` - HTTP server, middleware, and shared utilities
Expand All @@ -358,11 +403,14 @@ The project follows a modular domain-driven structure where each business domain
2. **Encapsulation** - Each domain is self-contained with clear boundaries
3. **Team Collaboration** - Teams can work on different domains independently
4. **Maintainability** - Related code is co-located, making it easier to understand and modify
5. **Dependency Management** - Centralized DI container simplifies component wiring and testing

### Adding New Domains

To add a new domain (e.g., `product`):

#### 1. Create the domain structure

```
internal/product/
├── domain/
Expand All @@ -379,11 +427,58 @@ internal/product/
└── product_handler.go # HTTP handlers
```

#### 2. Register in DI container

Add the new domain to the dependency injection container (`internal/app/di.go`):

```go
// Add fields to Container struct
type Container struct {
// ... existing fields
productRepo *productRepository.ProductRepository
productUseCase *productUsecase.ProductUseCase
productRepoInit sync.Once
productUseCaseInit sync.Once
}

// Add getter methods
func (c *Container) ProductRepository() (*productRepository.ProductRepository, error) {
var err error
c.productRepoInit.Do(func() {
c.productRepo, err = c.initProductRepository()
if err != nil {
c.initErrors["productRepo"] = err
}
})
// ... error handling
return c.productRepo, nil
}

// Add initialization methods
func (c *Container) initProductRepository() (*productRepository.ProductRepository, error) {
db, err := c.DB()
if err != nil {
return nil, fmt.Errorf("failed to get database: %w", err)
}
return productRepository.NewProductRepository(db, c.config.DBDriver), nil
}
```

#### 3. Wire handlers in HTTP server

Update `internal/http/server.go` to register product routes:

```go
productHandler := productHttp.NewProductHandler(container.ProductUseCase(), logger)
mux.HandleFunc("/api/products", productHandler.HandleProducts)
```

**Tips:**
- Use the shared `httputil.MakeJSONResponse` function in your HTTP handlers for consistent JSON responses
- Keep domain models free of JSON tags - use DTOs for API serialization
- Implement validation in your request DTOs
- Create mapper functions to convert between DTOs and domain models
- Register all components in the DI container for proper lifecycle management

### Clean Architecture Layers

Expand Down
114 changes: 30 additions & 84 deletions cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,26 @@ package main

import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"

"github.com/allisson/go-project-template/internal/app"
"github.com/allisson/go-project-template/internal/config"
"github.com/allisson/go-project-template/internal/database"
"github.com/allisson/go-project-template/internal/http"
outboxRepository "github.com/allisson/go-project-template/internal/outbox/repository"
userRepository "github.com/allisson/go-project-template/internal/user/repository"
userUsecase "github.com/allisson/go-project-template/internal/user/usecase"
"github.com/allisson/go-project-template/internal/worker"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/mysql"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/urfave/cli/v3"
)

// closeDB closes the database connection and logs any errors.
func closeDB(db *sql.DB, logger *slog.Logger) {
if err := db.Close(); err != nil {
logger.Error("failed to close the database", slog.Any("error", err))
// closeContainer closes all resources in the container and logs any errors.
func closeContainer(container *app.Container, logger *slog.Logger) {
if err := container.Shutdown(context.Background()); err != nil {
logger.Error("failed to shutdown container", slog.Any("error", err))
}
}

Expand Down Expand Up @@ -81,36 +75,22 @@ func runServer(ctx context.Context) error {
// Load configuration
cfg := config.Load()

// Setup logger
logger := setupLogger(cfg.LogLevel)
logger.Info("starting server", slog.String("version", "1.0.0"))
// Create DI container
container := app.NewContainer(cfg)

// Connect to database
db, err := database.Connect(database.Config{
Driver: cfg.DBDriver,
ConnectionString: cfg.DBConnectionString,
MaxOpenConnections: cfg.DBMaxOpenConnections,
MaxIdleConnections: cfg.DBMaxIdleConnections,
ConnMaxLifetime: cfg.DBConnMaxLifetime,
})
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
defer closeDB(db, logger)
// Get logger from container
logger := container.Logger()
logger.Info("starting server", slog.String("version", "1.0.0"))

// Initialize components
txManager := database.NewTxManager(db)
userRepo := userRepository.NewUserRepository(db, cfg.DBDriver)
outboxRepo := outboxRepository.NewOutboxEventRepository(db, cfg.DBDriver)
// Ensure cleanup on exit
defer closeContainer(container, logger)

userUseCaseInstance, err := userUsecase.NewUserUseCase(txManager, userRepo, outboxRepo)
// Get HTTP server from container (this initializes all dependencies)
server, err := container.HTTPServer()
if err != nil {
return fmt.Errorf("failed to create user use case: %w", err)
return fmt.Errorf("failed to initialize HTTP server: %w", err)
}

// Create HTTP server
server := http.NewServer(cfg.ServerHost, cfg.ServerPort, logger, userUseCaseInstance)

// Setup graceful shutdown
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer cancel()
Expand Down Expand Up @@ -142,7 +122,10 @@ func runServer(ctx context.Context) error {
// runMigrations executes database migrations based on the configured driver.
func runMigrations() error {
cfg := config.Load()
logger := setupLogger(cfg.LogLevel)

// Create container just for logger
container := app.NewContainer(cfg)
logger := container.Logger()

logger.Info("running database migrations",
slog.String("driver", cfg.DBDriver),
Expand Down Expand Up @@ -173,63 +156,26 @@ func runWorker(ctx context.Context) error {
// Load configuration
cfg := config.Load()

// Setup logger
logger := setupLogger(cfg.LogLevel)
logger.Info("starting worker", slog.String("version", "1.0.0"))
// Create DI container
container := app.NewContainer(cfg)

// Connect to database
db, err := database.Connect(database.Config{
Driver: cfg.DBDriver,
ConnectionString: cfg.DBConnectionString,
MaxOpenConnections: cfg.DBMaxOpenConnections,
MaxIdleConnections: cfg.DBMaxIdleConnections,
ConnMaxLifetime: cfg.DBConnMaxLifetime,
})
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
defer closeDB(db, logger)
// Get logger from container
logger := container.Logger()
logger.Info("starting worker", slog.String("version", "1.0.0"))

// Initialize components
txManager := database.NewTxManager(db)
outboxRepo := outboxRepository.NewOutboxEventRepository(db, cfg.DBDriver)
// Ensure cleanup on exit
defer closeContainer(container, logger)

workerConfig := worker.Config{
Interval: cfg.WorkerInterval,
BatchSize: cfg.WorkerBatchSize,
MaxRetries: cfg.WorkerMaxRetries,
RetryInterval: cfg.WorkerRetryInterval,
// Get event worker from container (this initializes all dependencies)
eventWorker, err := container.EventWorker()
if err != nil {
return fmt.Errorf("failed to initialize event worker: %w", err)
}

eventWorker := worker.NewEventWorker(workerConfig, txManager, outboxRepo, logger)

// Setup graceful shutdown
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer cancel()

// Start worker
return eventWorker.Start(ctx)
}

// setupLogger creates and configures a structured logger based on the specified log level.
func setupLogger(level string) *slog.Logger {
var logLevel slog.Level
switch level {
case "debug":
logLevel = slog.LevelDebug
case "info":
logLevel = slog.LevelInfo
case "warn":
logLevel = slog.LevelWarn
case "error":
logLevel = slog.LevelError
default:
logLevel = slog.LevelInfo
}

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
})

return slog.New(handler)
}
Loading
Loading