This document describes the architectural patterns and design principles used in this Go project template.
The project follows Clean Architecture principles with clear separation of concerns across five layers:
Location: internal/{domain}/domain/
Contains business entities, domain errors, and business rules.
Responsibilities:
- Define entities with pure business logic (no JSON tags)
- Define domain-specific errors by wrapping standard errors
- Implement domain validation rules
Example:
// internal/user/domain/user.go
package domain
import (
"time"
"github.com/google/uuid"
"github.com/allisson/go-project-template/internal/errors"
)
type User struct {
ID uuid.UUID
Name string
Email string
Password string
CreatedAt time.Time
UpdatedAt time.Time
}
// Domain-specific errors
var (
ErrUserNotFound = errors.Wrap(errors.ErrNotFound, "user not found")
ErrUserAlreadyExists = errors.Wrap(errors.ErrConflict, "user already exists")
ErrInvalidEmail = errors.Wrap(errors.ErrInvalidInput, "invalid email format")
)Location: internal/{domain}/repository/
Handles data persistence and retrieval. Implements separate repositories for MySQL and PostgreSQL.
Responsibilities:
- Implement data access for each database type
- Transform infrastructure errors to domain errors (e.g.,
sql.ErrNoRows→domain.ErrUserNotFound) - Use
database.GetTx(ctx, r.db)to support transactions - Handle database-specific concerns (UUID marshaling, placeholder syntax, etc.)
Key Differences:
| Feature | MySQL | PostgreSQL |
|---|---|---|
| UUID Storage | BINARY(16) - requires marshaling/unmarshaling |
Native UUID type |
| Placeholders | ? for all parameters |
$1, $2, $3... numbered parameters |
| Unique Errors | Check for "1062" or "duplicate entry" | Check for "duplicate key" or "unique constraint" |
Example:
// internal/user/repository/postgresql_user_repository.go
func (r *PostgreSQLUserRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
querier := database.GetTx(ctx, r.db)
query := `SELECT id, name, email, password, created_at, updated_at FROM users WHERE id = $1`
var user domain.User
err := querier.QueryRowContext(ctx, query, id).Scan(
&user.ID, &user.Name, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrUserNotFound // Transform to domain error
}
return nil, apperrors.Wrap(err, "failed to get user by id")
}
return &user, nil
}Location: internal/{domain}/usecase/
Implements business logic and orchestrates domain operations.
Responsibilities:
- Define UseCase interfaces for dependency inversion
- Implement business logic and orchestration
- Validate input using
github.com/jellydator/validation - Return domain errors directly without additional wrapping
- Manage transactions using
TxManager.WithTx()
Example:
// internal/user/usecase/user_usecase.go
type UseCase interface {
RegisterUser(ctx context.Context, input RegisterUserInput) (*domain.User, error)
}
func (uc *UserUseCase) RegisterUser(ctx context.Context, input RegisterUserInput) (*domain.User, error) {
// Validate input
if err := input.Validate(); err != nil {
return nil, err
}
// Business logic
hashedPassword, err := uc.passwordHasher.Hash([]byte(input.Password))
if err != nil {
return nil, apperrors.Wrap(err, "failed to hash password")
}
user := &domain.User{
ID: uuid.Must(uuid.NewV7()),
Name: input.Name,
Email: input.Email,
Password: string(hashedPassword),
}
// Transaction management
err = uc.txManager.WithTx(ctx, func(ctx context.Context) error {
if err := uc.userRepo.Create(ctx, user); err != nil {
return err // Pass through domain errors
}
// Create outbox event in same transaction
event := &outboxDomain.OutboxEvent{
ID: uuid.Must(uuid.NewV7()),
EventType: "user.created",
Payload: string(payload),
Status: outboxDomain.StatusPending,
}
return uc.outboxRepo.Create(ctx, event)
})
return user, err
}Location: internal/{domain}/http/
Contains HTTP handlers and DTOs (Data Transfer Objects).
Responsibilities:
- Define request/response DTOs with JSON tags
- Validate DTOs using
jellydator/validation - Use
httputil.HandleError()for automatic error-to-HTTP status mapping - Use
httputil.MakeJSONResponse()for consistent JSON responses - Depend on UseCase interfaces, not concrete implementations
DTO Structure:
dto/request.go- Request DTOs with validationdto/response.go- Response DTOs with JSON tagsdto/mapper.go- Conversion functions between DTOs and domain models
Example:
// internal/user/http/user_handler.go
func (h *UserHandler) RegisterUser(w http.ResponseWriter, r *http.Request) {
var req dto.RegisterUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httputil.HandleValidationError(w, err, h.logger)
return
}
if err := req.Validate(); err != nil {
httputil.HandleError(w, err, h.logger)
return
}
input := dto.ToRegisterUserInput(req)
user, err := h.userUseCase.RegisterUser(r.Context(), input)
if err != nil {
httputil.HandleError(w, err, h.logger) // Auto-maps domain errors to HTTP status
return
}
response := dto.ToUserResponse(user)
httputil.MakeJSONResponse(w, http.StatusCreated, response)
}Location: internal/{httputil,errors,validation}/
Provides shared utilities for error handling, HTTP responses, and validation.
Components:
internal/errors/- Standardized domain errors (ErrNotFound, ErrConflict, etc.)internal/httputil/- HTTP utilities (JSON responses, error mapping)internal/validation/- Custom validation rules (email, password strength, etc.)
The project follows the Dependency Inversion Principle where:
- High-level modules (use cases) define interfaces
- Low-level modules (repositories, handlers) implement those interfaces
- Dependencies point inward towards the domain
┌─────────────────────────────────────────┐
│ Presentation Layer (HTTP) │
│ - Depends on UseCase interfaces │
└──────────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Use Case Layer │
│ - Defines interfaces │
│ - Implements business logic │
└──────────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Domain Layer │
│ - Pure business entities │
│ - No external dependencies │
└──────────────────┬──────────────────────┘
│
▲
┌──────────────────┴──────────────────────┐
│ Repository Layer │
│ - Implements repository interfaces │
│ - Depends on domain entities │
└─────────────────────────────────────────┘
Each business domain is organized in its own directory with clear separation of concerns:
internal/
├── user/ # User domain module
│ ├── domain/ # User entities and domain errors
│ │ └── user.go
│ ├── usecase/ # User business logic
│ │ └── user_usecase.go
│ ├── repository/ # User data access
│ │ ├── mysql_user_repository.go
│ │ └── postgresql_user_repository.go
│ └── http/ # User HTTP handlers
│ ├── dto/ # Request/response DTOs
│ │ ├── request.go
│ │ ├── response.go
│ │ └── mapper.go
│ └── user_handler.go
├── outbox/ # Outbox domain module
│ ├── domain/ # Outbox entities and domain errors
│ ├── usecase/ # Outbox event processing logic
│ └── repository/ # Outbox data access
└── {new-domain}/ # Easy to add new domains
Benefits:
- 🎯 Scalability - Easy to add new domains without affecting existing code
- 🔒 Encapsulation - Each domain is self-contained with clear boundaries
- 👥 Team Collaboration - Teams can work on different domains independently
- 🔧 Maintainability - Related code is co-located
The DI container (internal/app/) manages all application components with:
- Centralized component wiring - All dependencies assembled in one place
- Lazy initialization - Components created only when first accessed
- Singleton pattern - Each component initialized once and reused
- Clean resource management - Unified shutdown for all resources
- Thread-safe - Safe for concurrent access across goroutines
Dependency Graph:
Container
├── Infrastructure (Database, Logger)
├── Repositories (User, Outbox)
├── Use Cases (User, Outbox)
└── Presentation (HTTP Server)
Example:
// 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)For more details, see internal/app/README.md.
The project uses UUIDv7 for all primary keys instead of auto-incrementing integers.
Benefits:
- ⏱️ Time-ordered: UUIDs include timestamp information
- 🌍 Globally unique: No collision risk across distributed systems
- 📊 Database friendly: Better index performance than random UUIDs (v4)
- 📈 Scalability: No need for centralized ID generation
- 🔀 Merge-friendly: Databases can be merged without ID conflicts
Implementation:
import "github.com/google/uuid"
user := &domain.User{
ID: uuid.Must(uuid.NewV7()),
Name: input.Name,
Email: input.Email,
Password: hashedPassword,
}Database Storage:
- PostgreSQL:
UUIDtype (native support) - MySQL:
BINARY(16)type (16-byte storage)
The project enforces clear boundaries between internal domain models and external API contracts.
Domain Models (internal/user/domain/user.go):
- Pure internal representation of business entities
- No JSON tags - completely decoupled from API serialization
- Focus on business rules and domain logic
DTOs (internal/user/http/dto/):
request.go- API request structures with validationresponse.go- API response structures with JSON tagsmapper.go- Conversion functions between DTOs and domain models
Benefits:
- 🔒 Separation of Concerns - Domain models evolve independently from API contracts
- 🛡️ Security - Sensitive fields (like passwords) never exposed in API responses
- 🔄 Flexibility - Different API views of same domain model
- 📚 Versioning - Easy to maintain multiple API versions
- ✅ Validation - Request validation happens at DTO level before reaching domain logic
The template implements a TxManager interface for handling database transactions:
type TxManager interface {
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
}Usage:
err := uc.txManager.WithTx(ctx, func(ctx context.Context) error {
if err := uc.userRepo.Create(ctx, user); err != nil {
return err
}
if err := uc.outboxRepo.Create(ctx, event); err != nil {
return err
}
return nil
})The transaction is automatically injected into the context and used by repositories via database.GetTx().
The project demonstrates the transactional outbox pattern for reliable event delivery using a use case-based architecture:
- 📝 Business operation (e.g., user creation) is executed
- 📬 Event is stored in outbox table in same transaction
- 🚀 Outbox use case processes pending events with configurable retry logic
- ✅ Events are marked as processed or failed
- 🔌 Extensible via the
EventProcessorinterface for custom event handling
Benefits:
- 🔒 Guaranteed delivery - Events never lost due to transaction rollback
- 🔁 At-least-once delivery - Events processed at least once
- 🎯 Consistency - Business operations and events always in sync
- 🔧 Extensibility - Custom event processors for different event types
Example (User Registration):
err = uc.txManager.WithTx(ctx, func(ctx context.Context) error {
// Create user
if err := uc.userRepo.Create(ctx, user); err != nil {
return err
}
// Create event in same transaction
event := &outboxDomain.OutboxEvent{
ID: uuid.Must(uuid.NewV7()),
EventType: "user.created",
Payload: userPayload,
Status: outboxDomain.StatusPending,
}
return uc.outboxRepo.Create(ctx, event)
})Processing Events:
The outbox use case (internal/outbox/usecase/outbox_usecase.go) processes these events asynchronously:
// Start the outbox event processor
outboxUseCase, err := container.OutboxUseCase()
if err != nil {
return fmt.Errorf("failed to initialize outbox use case: %w", err)
}
// Processes events in background
err = outboxUseCase.Start(ctx)Custom Event Processing:
You can create custom event processors by implementing the EventProcessor interface:
type CustomEventProcessor struct {
logger *slog.Logger
// Add your dependencies here (e.g., message queue client)
}
func (p *CustomEventProcessor) Process(ctx context.Context, event *domain.OutboxEvent) error {
// Your custom event processing logic
switch event.EventType {
case "user.created":
// Send to message queue, send notification, etc.
return p.publishToQueue(ctx, event)
default:
return fmt.Errorf("unknown event type: %s", event.EventType)
}
}Then register it in the DI container when initializing the outbox use case.