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
122 changes: 65 additions & 57 deletions internal/app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ This package provides a dependency injection (DI) container for assembling and m

## Overview

The DI container centralizes the creation and wiring of all application dependencies, including:
The DI container centralizes the creation and wiring of all application dependencies. Not everything is a container field:

- Infrastructure components (database, logger)
- Repositories (data access layer)
- Use cases (business logic layer)
- HTTP servers and handlers
- **Infrastructure** (database, logger, tx manager, keyring, metrics) — memoized `once[T]` fields, shared widely.
- **Use cases** (business logic) — memoized `once[T]` fields, because both the HTTP path and the CLI consume them.
- **Authorizer** — a single memoized `once[T]` shared by every feature's Route Module.
- **Repositories** — *not* container fields. Each is built inline inside its use case's `init` (single consumer, stateless over `*sql.DB`).
- **HTTP handlers + Route Modules** — *not* container fields. Each feature owns a `build<Feature>Module` that builds use case → handler → module in one place.

## Key Features

Expand Down Expand Up @@ -55,41 +56,37 @@ defer container.Shutdown(ctx)

### Dependency Graph

Container fields are the memoized nodes. Repositories and handlers do not appear
because they are built inline (repos inside use cases, handlers inside the
feature module builders).

```
Container
Container (memoized once[T] fields)
├── Config (provided)
├── Logger
│ └── depends on: Config.LogLevel
├── Database
│ └── depends on: Config.DB*
├── TxManager
│ └── depends on: Database
├── Crypto Services
│ ├── KMS Provider
│ │ └── depends on: Config.KMS*
│ └── Encryption Service
│ └── depends on: KMS Provider
├── Repositories (by domain)
│ ├── AuthRepository
│ │ └── depends on: Database
│ ├── SecretsRepository
│ │ └── depends on: Database
│ ├── TransitRepository
│ │ └── depends on: Database
│ └── TokenizationRepository
│ └── depends on: Database
├── Keyring (envelope encryption)
│ └── depends on: Database, MasterKeyChain (KMS)
├── Use Cases (by domain)
│ ├── AuthUseCase
│ │ └── depends on: AuthRepository, Crypto
│ ├── SecretsUseCase
│ │ └── depends on: SecretsRepository, Crypto
│ ├── TransitUseCase
│ │ └── depends on: TransitRepository, Crypto
│ └── TokenizationUseCase
│ └── depends on: TokenizationRepository, Crypto
│ ├── ClientUseCase / TokenUseCase / AuditLogUseCase
│ │ └── depends on: TxManager, inline repos, Keyring (via KeySigner)
│ ├── SecretUseCase
│ │ └── depends on: TxManager, inline repo, Keyring
│ ├── TransitKeyUseCase
│ │ └── depends on: TxManager, inline repo, Keyring
│ └── TokenizationKey/TokenizationUseCase
│ └── depends on: TxManager, inline repos, Keyring
├── Authorizer
│ └── depends on: AuditLogUseCase
└── HTTP Server
├── depends on: Logger, Config
└── depends on: All Use Cases
├── depends on: Logger, Config, global auth/rate-limit middleware
└── mounts: buildAuthModule, buildSecretsModule, buildTransitModule,
buildTokenizationModule (each: use case → handler → Route Module,
with the shared Authorizer + business metrics bound)
```

### Layer Separation
Expand Down Expand Up @@ -175,54 +172,65 @@ func setupTestContainer(t *testing.T) *app.Container {

## Adding New Components

To add a new component to the container:
### Adding a use case (or shared infrastructure)

Use cases and shared infrastructure are memoized `once[T]` fields.

### 1. Add field to Container struct
1. Add the field to the `Container` struct in `di.go`:

```go
type Container struct {
// ... existing fields

// New component
orderUseCase *orderUsecase.OrderUseCase
orderUseCaseInit sync.Once
}
orderUseCase once[orderUseCase.OrderUseCase]
```

### 2. Add getter method
2. Add the accessor + `init` (build the repository inline — it is a single
consumer, stateless over `*sql.DB`):

```go
func (c *Container) OrderUseCase() (*orderUsecase.OrderUseCase, error) {
var err error
c.orderUseCaseInit.Do(func() {
c.orderUseCase, err = c.initOrderUseCase()
if err != nil {
c.initErrors["orderUseCase"] = err
}
func (c *Container) OrderUseCase(ctx context.Context) (orderUseCase.OrderUseCase, error) {
return c.orderUseCase.get(func() (orderUseCase.OrderUseCase, error) {
return c.initOrderUseCase(ctx)
})
}

func (c *Container) initOrderUseCase(ctx context.Context) (orderUseCase.OrderUseCase, error) {
db, err := c.DB(ctx)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to get database for order use case: %w", err)
}
if storedErr, exists := c.initErrors["orderUseCase"]; exists {
return nil, storedErr
txManager, err := c.TxManager(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get tx manager for order use case: %w", err)
}
return c.orderUseCase, nil
return orderUseCase.NewOrderUseCase(txManager, orderRepository.NewOrderRepository(db)), nil
}
```

### 3. Add initialization method
### Adding a feature (new HTTP endpoints)

Each feature owns one `build<Feature>Module` that assembles use case → handler →
Route Module. Handlers are locals, never container fields.

```go
func (c *Container) initProductRepository() (productUsecase.ProductRepository, error) {
db, err := c.DB()
func (c *Container) buildOrderModule(ctx context.Context) (*orderHTTP.Module, error) {
uc, err := c.OrderUseCase(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get order use case for order module: %w", err)
}
authz, err := c.Authorizer(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get database: %w", err)
return nil, fmt.Errorf("failed to get authorizer for order module: %w", err)
}

return productRepository.NewProductRepository(db), nil
bm, err := c.BusinessMetrics(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get business metrics for order module: %w", err)
}
handler := orderHTTP.NewOrderHandler(uc, c.Logger())
return orderHTTP.NewModule(handler, authz, bm), nil
}
```

Then add `c.buildOrderModule(ctx)` to the `registrars` slice in `initHTTPServer`.

## Benefits of This Approach

### 1. Centralized Dependency Management
Expand Down
136 changes: 34 additions & 102 deletions internal/app/di.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,15 @@ import (
"github.com/allisson/go-pwdhash"
"github.com/gin-gonic/gin"

authDomain "github.com/allisson/secrets/internal/auth/domain"
authHTTP "github.com/allisson/secrets/internal/auth/http"
authUseCase "github.com/allisson/secrets/internal/auth/usecase"
"github.com/allisson/secrets/internal/config"
"github.com/allisson/secrets/internal/database"
"github.com/allisson/secrets/internal/http"
"github.com/allisson/secrets/internal/keyring"
"github.com/allisson/secrets/internal/metrics"
secretsDomain "github.com/allisson/secrets/internal/secrets/domain"
secretsHTTP "github.com/allisson/secrets/internal/secrets/http"
secretsUseCase "github.com/allisson/secrets/internal/secrets/usecase"
tokenizationDomain "github.com/allisson/secrets/internal/tokenization/domain"
tokenizationHTTP "github.com/allisson/secrets/internal/tokenization/http"
tokenizationUseCase "github.com/allisson/secrets/internal/tokenization/usecase"
transitDomain "github.com/allisson/secrets/internal/transit/domain"
transitHTTP "github.com/allisson/secrets/internal/transit/http"
transitUseCase "github.com/allisson/secrets/internal/transit/usecase"
)

Expand Down Expand Up @@ -55,16 +48,11 @@ type Container struct {
// Keyring (envelope encryption)
keyring once[keyring.Keyring]

// Repositories
secretRepository once[secretsDomain.SecretRepository]
clientRepository once[authDomain.ClientRepository]
tokenRepository once[authDomain.TokenRepository]
auditLogRepository once[authDomain.AuditLogRepository]
transitKeyRepository once[transitDomain.TransitKeyRepository]
tokenizationKeyRepository once[tokenizationDomain.TokenizationKeyRepository]
tokenizationTokenRepository once[tokenizationDomain.TokenRepository]

// Use Cases
// Use Cases. Repositories and HTTP handlers are not container fields:
// repositories are built inline inside each use case's init (single
// consumer, stateless over *sql.DB), and handlers are built inline inside
// each feature's build<Feature>Module. Use cases remain here because both
// the HTTP path and the CLI consume them.
kekUseCase once[keyring.KekUseCase]
secretUseCase once[secretsUseCase.SecretUseCase]
clientUseCase once[authUseCase.ClientUseCase]
Expand All @@ -74,15 +62,8 @@ type Container struct {
tokenizationKeyUseCase once[tokenizationUseCase.TokenizationKeyUseCase]
tokenizationUseCase once[tokenizationUseCase.TokenizationUseCase]

// HTTP Handlers
clientHandler once[*authHTTP.ClientHandler]
tokenHandler once[*authHTTP.TokenHandler]
auditLogHandler once[*authHTTP.AuditLogHandler]
secretHandler once[*secretsHTTP.SecretHandler]
transitKeyHandler once[*transitHTTP.TransitKeyHandler]
cryptoHandler once[*transitHTTP.CryptoHandler]
tokenizationKeyHandler once[*tokenizationHTTP.TokenizationKeyHandler]
tokenizationHandler once[*tokenizationHTTP.TokenizationHandler]
// Authorizer — shared by every feature's Route Module.
authorizer once[*authHTTP.Authorizer]

// Servers
httpServer once[*http.Server]
Expand Down Expand Up @@ -287,68 +268,19 @@ func (c *Container) initHTTPServer(ctx context.Context) (*http.Server, error) {
logger,
)

clientHandler, err := c.ClientHandler(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get client handler: %w", err)
}

tokenHandler, err := c.TokenHandler(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get token handler: %w", err)
}

auditLogHandler, err := c.AuditLogHandler(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get audit log handler: %w", err)
}

secretHandler, err := c.SecretHandler(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get secret handler: %w", err)
}

transitKeyHandler, err := c.TransitKeyHandler(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get transit key handler: %w", err)
}

cryptoHandler, err := c.CryptoHandler(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get crypto handler: %w", err)
}

tokenizationKeyHandler, err := c.TokenizationKeyHandler(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get tokenization key handler: %w", err)
}

tokenizationHandler, err := c.TokenizationHandler(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get tokenization handler: %w", err)
}

tokenUseCase, err := c.TokenUseCase(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get token use case: %w", err)
}

auditLogUseCase, err := c.AuditLogUseCase(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get audit log use case: %w", err)
}

metricsProvider, err := c.MetricsProvider(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get metrics provider: %w", err)
}

businessMetrics, err := c.BusinessMetrics(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get business metrics: %w", err)
}

// Build the shared per-route middleware. Authentication is always present;
// the two rate limiters are optional and stay nil when disabled.
// Build the global middleware chain handed to SetupRouter. Authentication is
// always present; the shared rate limiter is optional and stays nil when
// disabled. Per-route authorization and metrics live inside each module.
authMiddleware := authHTTP.AuthenticationMiddleware(tokenUseCase, logger)

var rateLimitMiddleware gin.HandlerFunc
Expand All @@ -361,34 +293,34 @@ func (c *Container) initHTTPServer(ctx context.Context) (*http.Server, error) {
)
}

var tokenRateLimitMiddleware gin.HandlerFunc
if c.config.RateLimitTokenEnabled {
tokenRateLimitMiddleware = authHTTP.TokenRateLimitMiddleware(
ctx,
c.config.RateLimitTokenRequestsPerSec,
c.config.RateLimitTokenBurst,
logger,
)
// Each feature owns its wiring behind a build<Feature>Module; the composition
// root assembles the Route Modules and the server mounts them without knowing
// any feature type.
authModule, err := c.buildAuthModule(ctx)
if err != nil {
return nil, fmt.Errorf("failed to build auth module: %w", err)
}

secretsModule, err := c.buildSecretsModule(ctx)
if err != nil {
return nil, fmt.Errorf("failed to build secrets module: %w", err)
}

// Build the per-route authorizer once; each module captures it so route
// registrations only carry the capability.
authz := authHTTP.NewAuthorizer(auditLogUseCase, logger)
transitModule, err := c.buildTransitModule(ctx)
if err != nil {
return nil, fmt.Errorf("failed to build transit module: %w", err)
}

tokenizationModule, err := c.buildTokenizationModule(ctx)
if err != nil {
return nil, fmt.Errorf("failed to build tokenization module: %w", err)
}

// Each feature owns its route registration; the composition root assembles
// the modules and the server mounts them without knowing any feature type.
registrars := []http.RouteRegistrar{
authHTTP.NewModule(
clientHandler,
tokenHandler,
auditLogHandler,
authz,
businessMetrics,
tokenRateLimitMiddleware,
),
secretsHTTP.NewModule(secretHandler, authz, businessMetrics),
transitHTTP.NewModule(transitKeyHandler, cryptoHandler, authz, businessMetrics),
tokenizationHTTP.NewModule(tokenizationKeyHandler, tokenizationHandler, authz, businessMetrics),
authModule,
secretsModule,
transitModule,
tokenizationModule,
}

server.SetupRouter(
Expand Down
Loading
Loading